Files
multica/packages/views/chat/components/chat-thread-list.test.tsx
Bohan Jiang cb87dd106b feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351) (#5195)
* feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351)

Direct (web/mobile) chat no longer uses the last-assistant-row as an implicit
input cursor. Each direct send now owns an immutable input batch:

- agent_task_queue.chat_input_task_id makes a task the owner of the user
  messages it must consume; the send path creates the task + user message +
  attachment bindings + session touch in one transaction, and the daemon is
  notified only after commit. A claim reads exactly that batch, so a message
  that arrives mid-run belongs to the next task and is never absorbed.
- Auto-retry inherits the root input owner and is queued at a bumped priority,
  created inside FailTask's transaction so no newer chat task can jump ahead.
- CompleteTask writes exactly one assistant outcome inside the completion
  transaction: a normal message, or a visible no_response outcome (with a
  non-empty English fallback) when the final output is empty. The write failing
  rolls the completion back and the handler returns 5xx so the daemon retries;
  the status CAS keeps it idempotent. chat:done carries message_kind.
- Web/desktop/mobile render no_response as a localized 'no text reply' state
  (keeping the tool timeline), suppress Copy, keep it unread, and keep the
  session-list preview non-blank.
- Legacy/channel tasks (chat_input_task_id NULL) keep the trailing-message
  selector, so a rolling deploy never replays Slack/Lark history.

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): scope no_response to direct tasks; don't cancel task on input read error (MUL-4351)

Addresses PR review (Niko):
- writeChatCompletionOutcome only writes a no_response row for task-owned
  direct tasks (chat_input_task_id set). Legacy/channel (Slack/Lark) tasks keep
  the prior behavior: empty output writes no assistant row, so chat:done carries
  empty content and the channel outbound silently drops it — the no_response
  fallback body never reaches an external channel.
- The daemon claim distinguishes a genuine zero-input batch from a failed
  input read: on ListChatInputMessages / ListChatMessages error it returns 5xx
  and preserves the dispatched task for redelivery instead of cancelling a valid
  task on a transient DB error.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-10 16:22:42 +08:00

173 lines
6.0 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { I18nProvider } from "@multica/core/i18n/react";
import type { Agent, ChatSession } from "@multica/core/types";
import enChat from "../../locales/en/chat.json";
import enIssues from "../../locales/en/issues.json";
// --- Mocks ------------------------------------------------------------------
// The list no longer owns the archive-advance behavior — the parent (ChatPage)
// does, so it stays layout-aware and routes through the shared controller. Here
// we only assert the history-row Archive action delegates to the `onArchive`
// prop and does NOT fire the archive mutation itself. The advance semantics
// (next / previous / clear / cross-agent sync) are covered in the controller
// test, and mobile-vs-desktop in the page. setActiveSession/archiveMutate are
// kept as negative assertions: the list must not touch them for a history
// archive anymore.
const setActiveSession = vi.fn();
const archiveMutate = vi.fn();
vi.mock("../../common/actor-avatar", () => ({
ActorAvatar: ({ actorId }: { actorId: string }) => (
<span data-testid={`avatar-${actorId}`} />
),
}));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
vi.mock("@multica/core/agents", () => ({
useWorkspacePresenceMap: () => ({ byAgent: new Map() }),
}));
vi.mock("@multica/core/api", () => ({
api: { cancelTaskById: vi.fn() },
}));
vi.mock("@multica/core/chat", () => ({
useChatStore: (selector: (s: { setActiveSession: typeof setActiveSession }) => unknown) =>
selector({ setActiveSession }),
}));
vi.mock("@multica/core/chat/mutations", () => ({
useDeleteChatSession: () => ({ mutate: vi.fn(), isPending: false }),
useSetChatSessionPinned: () => ({ mutate: vi.fn(), isPending: false }),
useSetChatSessionArchived: () => ({ mutate: archiveMutate, isPending: false }),
}));
vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
return {
...actual,
useQuery: () => ({ data: { tasks: [] } }),
useQueryClient: () => ({ setQueryData: vi.fn(), invalidateQueries: vi.fn() }),
};
});
import { ChatThreadList } from "./chat-thread-list";
const TEST_RESOURCES = { en: { chat: enChat, issues: enIssues } };
function makeSession(overrides: Partial<ChatSession> & Pick<ChatSession, "id">): ChatSession {
return {
workspace_id: "ws-1",
agent_id: "agent-1",
creator_id: "user-1",
title: `Chat ${overrides.id}`,
status: "active",
has_unread: false,
unread_count: 0,
last_message: null,
pinned: false,
created_at: new Date(0).toISOString(),
updated_at: new Date(0).toISOString(),
...overrides,
};
}
const agent = { id: "agent-1", name: "Alpha" } as unknown as Agent;
// Sessions are sorted by most-recent activity (updated_at) first, pinned first.
// Give them descending timestamps so the rendered order is s1, s2, s3.
const sessions: ChatSession[] = [
makeSession({ id: "s1", updated_at: "2026-07-08T03:00:00Z" }),
makeSession({ id: "s2", updated_at: "2026-07-08T02:00:00Z" }),
makeSession({ id: "s3", updated_at: "2026-07-08T01:00:00Z" }),
];
function renderList(activeSessionId: string | null, onArchive = vi.fn()) {
render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<ChatThreadList
sessions={sessions}
agents={[agent]}
activeSessionId={activeSessionId}
onSelectSession={vi.fn()}
onArchive={onArchive}
/>
</I18nProvider>,
);
return onArchive;
}
const ARCHIVE_LABEL = enChat.list.archive;
describe("ChatThreadList archive delegation", () => {
beforeEach(() => {
setActiveSession.mockClear();
archiveMutate.mockClear();
});
it("delegates the history-row Archive action to onArchive with that session", () => {
const onArchive = renderList("s2");
// Rows render in order s1, s2, s3 → the second archive button is s2's.
const archiveButtons = screen.getAllByRole("button", { name: ARCHIVE_LABEL });
fireEvent.click(archiveButtons[1]!);
expect(onArchive).toHaveBeenCalledTimes(1);
expect(onArchive.mock.calls[0]![0]).toMatchObject({ id: "s2" });
});
it("passes the archived row's session even when it isn't the open one", () => {
const onArchive = renderList("s1");
const archiveButtons = screen.getAllByRole("button", { name: ARCHIVE_LABEL });
// Archive s3 while s1 is the open one — the parent decides what to do.
fireEvent.click(archiveButtons[2]!);
expect(onArchive).toHaveBeenCalledTimes(1);
expect(onArchive.mock.calls[0]![0]).toMatchObject({ id: "s3" });
});
it("does not flip archive status or move selection itself", () => {
renderList("s2");
const archiveButtons = screen.getAllByRole("button", { name: ARCHIVE_LABEL });
fireEvent.click(archiveButtons[1]!);
// Advance + mutation are the parent/controller's job now; the list stays out.
expect(setActiveSession).not.toHaveBeenCalled();
expect(archiveMutate).not.toHaveBeenCalled();
});
});
describe("ChatThreadList no_response preview (MUL-4351)", () => {
it("shows a localized 'no text reply' preview instead of the fallback body", () => {
const session = makeSession({
id: "nr1",
last_message: {
content: "The agent finished this turn without a text reply.",
role: "assistant",
created_at: "2026-07-08T03:00:00Z",
message_kind: "no_response",
},
});
render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<ChatThreadList
sessions={[session]}
agents={[agent]}
activeSessionId={null}
onSelectSession={vi.fn()}
onArchive={vi.fn()}
/>
</I18nProvider>,
);
expect(screen.getByText(enChat.list.no_response_preview)).toBeInTheDocument();
// The stored English fallback body must not leak into the preview.
expect(
screen.queryByText("The agent finished this turn without a text reply."),
).not.toBeInTheDocument();
});
});