Fix a few bugs

This commit is contained in:
Kyle Carberry
2026-02-24 11:32:50 -05:00
parent 7d3d448531
commit a64aefe246
7 changed files with 376 additions and 9 deletions
+12
View File
@@ -439,6 +439,18 @@ func (p *Processor) InterruptChat(chatID uuid.UUID) bool {
return true
}
// IsChatActive reports whether the processor currently has an in-flight
// worker for the chat.
func (p *Processor) IsChatActive(chatID uuid.UUID) bool {
if p == nil {
return false
}
p.activeMu.Lock()
_, ok := p.activeCancels[chatID]
p.activeMu.Unlock()
return ok
}
func (p *Processor) EnsureLocalWorkspaceBinding(
ctx context.Context,
ownerID uuid.UUID,
+19 -2
View File
@@ -818,8 +818,10 @@ func (api *API) createChatMessage(rw http.ResponseWriter, r *http.Request) {
return xerrors.Errorf("lock chat: %w", err)
}
// If the chat is busy (running or pending), queue the message.
if lockedChat.Status == database.ChatStatusRunning || lockedChat.Status == database.ChatStatusPending {
isChatActive := api.chatProcessor != nil && api.chatProcessor.IsChatActive(chatID)
// If the chat is still being processed, queue the message.
if shouldQueueUserMessage(lockedChat.Status, isChatActive) {
// Check queue size limit.
existingQueuedMessages, err := tx.GetChatQueuedMessages(ctx, chatID)
if err != nil {
@@ -3089,6 +3091,21 @@ func convertChatQueuedMessages(msgs []database.ChatQueuedMessage) []codersdk.Cha
return result
}
func shouldQueueUserMessage(status database.ChatStatus, isChatActive bool) bool {
switch status {
case database.ChatStatusRunning, database.ChatStatusPending:
return true
case database.ChatStatusWaiting:
// During interrupt the API may briefly set the chat to waiting
// before the worker finishes persisting an interrupted partial step.
// Keep new user messages queued while that worker is still active so
// assistant output remains ordered before follow-up prompts.
return isChatActive
default:
return false
}
}
func convertChatMessage(m database.ChatMessage) codersdk.ChatMessage {
return db2sdk.ChatMessage(m)
}
+54
View File
@@ -181,6 +181,60 @@ func TestConvertChatMessagesSkipsWorkspaceMetadata(t *testing.T) {
require.Equal(t, int64(1), converted[0].ID)
}
func TestShouldQueueUserMessage(t *testing.T) {
t.Parallel()
cases := []struct {
name string
status database.ChatStatus
isChatActive bool
expected bool
}{
{
name: "RunningAlwaysQueues",
status: database.ChatStatusRunning,
isChatActive: false,
expected: true,
},
{
name: "PendingAlwaysQueues",
status: database.ChatStatusPending,
isChatActive: false,
expected: true,
},
{
name: "WaitingQueuesWhileWorkerActive",
status: database.ChatStatusWaiting,
isChatActive: true,
expected: true,
},
{
name: "WaitingDoesNotQueueWhenIdle",
status: database.ChatStatusWaiting,
isChatActive: false,
expected: false,
},
{
name: "CompletedDoesNotQueue",
status: database.ChatStatusCompleted,
isChatActive: true,
expected: false,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
require.Equal(
t,
tc.expected,
shouldQueueUserMessage(tc.status, tc.isChatActive),
)
})
}
}
func TestConvertChatIncludesHierarchyMetadata(t *testing.T) {
t.Parallel()
@@ -3,6 +3,12 @@ import { renderComponent } from "testHelpers/renderHelpers";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ChatModelsResponse } from "api/api";
import {
chatDiffContentsKey,
chatDiffStatusKey,
chatKey,
chatsKey,
} from "api/queries/chats";
import type * as TypesGen from "api/typesGenerated";
import type { ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
@@ -512,6 +518,234 @@ describe(AgentDetail.name, () => {
expect(invalidateQueries).not.toHaveBeenCalled();
});
it("refreshes active chat data on terminal parent status updates", async () => {
const socket = createMockSocket();
mockWatchChat.mockReturnValue(socket);
const setQueryData = vi.fn();
const invalidateQueries = vi.fn().mockResolvedValue(undefined);
mockUseQueryClient.mockReturnValue({
setQueryData,
invalidateQueries,
});
const workspace = {
...MockWorkspace,
id: "workspace-1",
};
const chatData: TypesGen.ChatWithMessages = {
chat: {
id: "chat-1",
owner_id: "owner-id",
workspace_id: workspace.id,
workspace_agent_id: MockWorkspaceAgent.id,
title: "Parent agent",
status: "running",
model_config: {
model: "gpt-4o",
provider: "openai",
},
created_at: "2026-02-18T00:00:00.000Z",
updated_at: "2026-02-18T00:00:00.000Z",
},
messages: [],
queued_messages: [],
};
mockUseOutletContext.mockReturnValue({
chatErrorReasons: {},
setChatErrorReason: vi.fn(),
clearChatErrorReason: vi.fn(),
topBarTitleRef: { current: null },
topBarActionsRef: { current: null },
rightPanelRef: { current: null },
setRightPanelOpen: vi.fn(),
requestArchiveAgent: vi.fn(),
});
installQueryMocks({
chatData,
workspaceData: workspace,
diffUrl: undefined,
});
renderComponent(<AgentDetail />);
socket.emitMessage({
parseError: null,
parsedMessage: {
type: "data",
data: {
type: "status",
chat_id: "chat-1",
status: {
status: "completed",
},
},
},
});
await waitFor(() => {
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: chatDiffStatusKey("chat-1"),
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: chatDiffContentsKey("chat-1"),
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: chatsKey,
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: chatKey("chat-1"),
});
});
});
it("preserves streamed messages when chat query updates metadata only", async () => {
const socket = createMockSocket();
mockWatchChat.mockReturnValue(socket);
const setQueryData = vi.fn();
const invalidateQueries = vi.fn().mockResolvedValue(undefined);
mockUseQueryClient.mockReturnValue({
setQueryData,
invalidateQueries,
});
const workspace = {
...MockWorkspace,
id: "workspace-1",
};
const chatData: TypesGen.ChatWithMessages = {
chat: {
id: "chat-1",
owner_id: "owner-id",
workspace_id: workspace.id,
workspace_agent_id: MockWorkspaceAgent.id,
title: "Queued test",
status: "running",
model_config: {
model: "gpt-4o",
provider: "openai",
},
created_at: "2026-02-18T00:00:00.000Z",
updated_at: "2026-02-18T00:00:00.000Z",
},
messages: [],
queued_messages: [],
};
mockUseOutletContext.mockReturnValue({
chatErrorReasons: {},
setChatErrorReason: vi.fn(),
clearChatErrorReason: vi.fn(),
topBarTitleRef: { current: null },
topBarActionsRef: { current: null },
rightPanelRef: { current: null },
setRightPanelOpen: vi.fn(),
requestArchiveAgent: vi.fn(),
});
mockUseQuery.mockImplementation(
(query: { queryKey?: readonly unknown[] } | undefined) => {
const queryKey = query?.queryKey ?? [];
if (queryKey[0] === "chat" && queryKey[2] === "diff-status") {
return makeQueryResult({
data: {
chat_id: chatData.chat.id,
url: undefined,
changes_requested: false,
additions: 0,
deletions: 0,
changed_files: 0,
},
});
}
if (queryKey[0] === "chat" && queryKey.length === 2) {
return makeQueryResult({
// Return a fresh object each render to mimic
// metadata-only cache updates (status/title) that
// don't include new messages.
data: {
...chatData,
chat: {
...chatData.chat,
},
},
});
}
if (queryKey[0] === "workspace") {
return makeQueryResult({ data: workspace });
}
if (queryKey[0] === "chat-models") {
return makeQueryResult({
data: {
providers: [
{
provider: "openai",
available: true,
models: [
{
id: "openai:gpt-4o",
provider: "openai",
model: "gpt-4o",
display_name: "GPT-4o",
},
],
},
],
},
});
}
return makeQueryResult();
},
);
renderComponent(<AgentDetail />);
socket.emitMessage({
parseError: null,
parsedMessage: {
type: "data",
data: {
type: "message",
chat_id: "chat-1",
message: {
id: 42,
chat_id: "chat-1",
created_at: "2026-02-18T00:00:01.000Z",
role: "assistant",
content: {
text: "first queued response",
},
hidden: false,
},
},
},
});
await waitFor(() => {
expect(screen.getByText("first queued response")).toBeInTheDocument();
});
socket.emitMessage({
parseError: null,
parsedMessage: {
type: "data",
data: {
type: "status",
chat_id: "chat-1",
status: {
status: "waiting",
},
},
},
});
await waitFor(() => {
expect(screen.getByText("first queued response")).toBeInTheDocument();
});
});
it("renders streamed subagent title before args JSON is complete", async () => {
const socket = createMockSocket();
mockWatchChat.mockReturnValue(socket);
+34 -6
View File
@@ -1,6 +1,7 @@
import { API, type ChatDiffStatusResponse, watchChat } from "api/api";
import {
chat,
chatKey,
chatDiffContentsKey,
chatDiffStatus,
chatDiffStatusKey,
@@ -1456,16 +1457,28 @@ export const AgentDetail: FC = () => {
useEffect(() => {
if (!chatQuery.data) {
setMessagesById(new Map());
setChatStatus(null);
setQueuedMessages([]);
return;
}
setMessagesById(
new Map(chatQuery.data.messages.map((message) => [message.id, message])),
);
}, [chatQuery.data?.messages]);
useEffect(() => {
if (!chatQuery.data) {
setChatStatus(null);
return;
}
setChatStatus(chatQuery.data.chat.status);
}, [chatQuery.data?.chat.status]);
useEffect(() => {
if (!chatQuery.data) {
setQueuedMessages([]);
return;
}
setQueuedMessages(chatQuery.data.queued_messages ?? []);
}, [chatQuery.data]);
}, [chatQuery.data?.queued_messages]);
useEffect(() => {
if (!chatQuery.data) {
@@ -1734,7 +1747,12 @@ export const AgentDetail: FC = () => {
nextStatus === "paused" ||
nextStatus === "waiting";
if (shouldRefreshQueries) {
void queryClient.invalidateQueries({ queryKey: chatsKey });
void Promise.all([
queryClient.invalidateQueries({ queryKey: chatsKey }),
queryClient.invalidateQueries({
queryKey: chatKey(agentId),
}),
]);
}
return;
}
@@ -1752,7 +1770,12 @@ export const AgentDetail: FC = () => {
status: "error",
updated_at: new Date().toISOString(),
}));
void queryClient.invalidateQueries({ queryKey: chatsKey });
void Promise.all([
queryClient.invalidateQueries({ queryKey: chatsKey }),
queryClient.invalidateQueries({
queryKey: chatKey(agentId),
}),
]);
return;
}
default:
@@ -1762,7 +1785,12 @@ export const AgentDetail: FC = () => {
const handleError = () => {
setStreamError((current) => current ?? "Chat stream disconnected.");
void queryClient.invalidateQueries({ queryKey: chatsKey });
void Promise.all([
queryClient.invalidateQueries({ queryKey: chatsKey }),
queryClient.invalidateQueries({
queryKey: chatKey(agentId),
}),
]);
};
socket.addEventListener("message", handleMessage);
+22
View File
@@ -2,6 +2,7 @@ import type { ChatModelsResponse } from "api/api";
import { watchChats } from "api/api";
import { getErrorMessage } from "api/errors";
import {
chatKey,
chatModels,
chats,
chatsKey,
@@ -256,6 +257,10 @@ export const AgentsPage: FC = () => {
(prev: TypesGen.Chat[] | undefined) =>
prev?.filter((c) => c.id !== updatedChat.id),
);
queryClient.removeQueries({
queryKey: chatKey(updatedChat.id),
exact: true,
});
return;
}
@@ -282,6 +287,23 @@ export const AgentsPage: FC = () => {
return prev;
},
);
queryClient.setQueryData<TypesGen.ChatWithMessages | undefined>(
chatKey(updatedChat.id),
(previousChat) => {
if (!previousChat) {
return previousChat;
}
return {
...previousChat,
chat: {
...previousChat.chat,
status: updatedChat.status,
title: updatedChat.title,
updated_at: updatedChat.updated_at,
},
};
},
);
});
return () => ws.close();
}, [queryClient]);
+1 -1
View File
@@ -425,7 +425,7 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
size="icon"
variant="subtle"
className={cn(
"absolute inset-0 h-6 w-7 justify-end rounded-none px-0 text-content-secondary opacity-0 transition-opacity hover:text-content-primary group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100",
"absolute inset-0 h-6 w-7 justify-end rounded-none px-0 text-content-secondary opacity-0 transition-opacity hover:text-content-primary group-hover:opacity-100",
isArchivingThisChat && "opacity-100",
)}
aria-label={`Open actions for ${chat.title}`}