mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
Fix a few bugs
This commit is contained in:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
Reference in New Issue
Block a user