Files
tutti/packages/agent/gui/agent-gui/agentGuiNode/AgentMentionIssueTopicGroups.spec.ts
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

670 lines
21 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import type {
RichTextTriggerGroupPageQueryInput,
RichTextTriggerGroupedQueryResult,
RichTextTriggerQueryInput
} from "@tutti-os/ui-rich-text/types";
import type { AgentContextMentionProvider } from "./agentContextMentionProvider";
import { AGENT_CONTEXT_MENTION_PROVIDER_IDS } from "./agentContextMentionProvider";
import {
AgentMentionSearchController,
resetAgentMentionSearchBrowseCacheForTests,
type AgentMentionSearchState
} from "./AgentMentionSearchController";
import { flattenAgentMentionPaletteEntries } from "./AgentFileMentionPalette";
import { mentionGroupExpandCount } from "./agentMentionSearchHelpers";
interface TestIssue {
issueId: string;
status: string;
title: string;
topicId: string;
workspaceId: string;
}
interface Deferred<T> {
promise: Promise<T>;
reject: (reason?: unknown) => void;
resolve: (value: T) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
function issue(issueId: string, topicId: string): TestIssue {
return {
issueId,
status: "open",
title: issueId,
topicId,
workspaceId: "workspace-1"
};
}
function groupedIssueProvider(input: {
queryGroups: NonNullable<
AgentContextMentionProvider<TestIssue>["queryGroups"]
>;
queryGroupPage?: NonNullable<
AgentContextMentionProvider<TestIssue>["queryGroupPage"]
>;
}): AgentContextMentionProvider<TestIssue> {
return {
id: AGENT_CONTEXT_MENTION_PROVIDER_IDS.workspaceIssue,
trigger: "@",
query: async () => [],
queryGroups: input.queryGroups,
...(input.queryGroupPage ? { queryGroupPage: input.queryGroupPage } : {}),
getItemKey: (item) => item.issueId,
getItemLabel: (item) => item.title,
toInsertResult: (item) => ({
kind: "mention",
mention: {
entityId: item.issueId,
label: item.title,
scope: {
topicId: item.topicId,
workspaceId: item.workspaceId
},
presentation: { status: item.status }
}
})
};
}
function groupedResult(
groups: RichTextTriggerGroupedQueryResult<TestIssue>["groups"]
): RichTextTriggerGroupedQueryResult<TestIssue> {
return { groups };
}
function abortError(): Error {
const error = new Error("aborted");
error.name = "AbortError";
return error;
}
describe("AgentMentionSearchController issue topic groups", () => {
afterEach(() => {
vi.useRealTimers();
resetAgentMentionSearchBrowseCacheForTests();
});
it("atomically replaces search groups, encodes dynamic ids, and rejects an aborted stale search", async () => {
vi.useFakeTimers();
const first = deferred<RichTextTriggerGroupedQueryResult<TestIssue>>();
const second = deferred<RichTextTriggerGroupedQueryResult<TestIssue>>();
const signals: AbortSignal[] = [];
const queryGroups = vi.fn(
({ abortSignal, keyword }: RichTextTriggerQueryInput) => {
signals.push(abortSignal!);
return keyword === "first" ? first.promise : second.promise;
}
);
const controller = new AgentMentionSearchController({
contextMentionProviders: [groupedIssueProvider({ queryGroups })]
});
const states: AgentMentionSearchState[] = [];
controller.subscribe((state) => states.push(state));
controller.setFilter("issue");
controller.updateQuery({ workspaceId: "workspace-1", query: "first" });
await vi.advanceTimersByTimeAsync(120);
expect(queryGroups).toHaveBeenCalledTimes(1);
controller.updateQuery({ workspaceId: "workspace-1", query: "second" });
expect(signals[0]?.aborted).toBe(true);
await vi.advanceTimersByTimeAsync(120);
expect(queryGroups).toHaveBeenCalledTimes(2);
expect(states.at(-1)?.status).toBe("loading");
expect(
states.at(-1)?.groups.some((group) => group.id.startsWith("issue-topic:"))
).toBe(false);
first.resolve(
groupedResult([
{
id: "stale/topic",
label: "Stale",
items: [issue("stale-1", "stale/topic")],
totalCount: 1
}
])
);
second.resolve(
groupedResult([
{
id: "topic/one",
label: "Pinned",
items: [issue("issue-1", "topic/one")],
totalCount: 11,
nextCursor: "cursor-1"
},
{
id: "topic:two",
label: "Recent",
items: [issue("issue-2", "topic:two")],
totalCount: 1
}
])
);
await vi.waitFor(() =>
expect(states.at(-1)).toMatchObject({
status: "ready",
query: "second",
groups: [
{
id: "issue-topic:topic%2Fone",
label: "Pinned",
totalCount: 11,
hasMore: true,
items: [expect.objectContaining({ targetId: "issue-1" })]
},
{
id: "issue-topic:topic%3Atwo",
label: "Recent",
totalCount: 1,
hasMore: false,
items: [expect.objectContaining({ targetId: "issue-2" })]
}
]
})
);
expect(states.at(-1)?.groups.some((group) => group.label === "Stale")).toBe(
false
);
});
it("keeps rows ready during load more, dedupes clicks and items, retries failures, and restores the browse cache before revalidation", async () => {
const failedPage = deferred<{
id: string;
label: string;
items: TestIssue[];
totalCount: number;
nextCursor?: string;
}>();
const retryPage = deferred<{
id: string;
label: string;
items: TestIssue[];
totalCount: number;
nextCursor?: string;
}>();
const refresh = deferred<RichTextTriggerGroupedQueryResult<TestIssue>>();
const queryGroups = vi
.fn()
.mockResolvedValueOnce(
groupedResult([
{
id: "topic/one",
label: "Pinned",
items: [issue("issue-1", "topic/one")],
totalCount: 2,
nextCursor: "cursor-1"
},
{
id: "topic:two",
label: "Recent",
items: [issue("issue-3", "topic:two")],
totalCount: 1
}
])
)
.mockReturnValueOnce(refresh.promise);
const pageSignals: AbortSignal[] = [];
let pageCallCount = 0;
const queryGroupPage = vi.fn(
(input: RichTextTriggerGroupPageQueryInput) => {
pageSignals.push(input.abortSignal!);
pageCallCount += 1;
return pageCallCount === 1 ? failedPage.promise : retryPage.promise;
}
);
const provider = groupedIssueProvider({ queryGroups, queryGroupPage });
const controller = new AgentMentionSearchController({
contextMentionProviders: [provider]
});
const states: AgentMentionSearchState[] = [];
controller.subscribe((state) => states.push(state));
controller.setFilter("issue");
controller.updateQuery({ workspaceId: "workspace-1", query: "" });
await vi.waitFor(() => expect(states.at(-1)?.status).toBe("ready"));
const groupId = "issue-topic:topic%2Fone";
controller.expandGroup(groupId);
controller.expandGroup(groupId);
await vi.waitFor(() => expect(queryGroupPage).toHaveBeenCalledTimes(1));
expect(states.at(-1)).toMatchObject({
status: "ready",
groups: [
{
id: groupId,
expandStatus: "loading",
items: [expect.objectContaining({ targetId: "issue-1" })]
},
expect.objectContaining({ id: "issue-topic:topic%3Atwo" })
]
});
failedPage.reject(new Error("page unavailable"));
await vi.waitFor(() =>
expect(states.at(-1)?.groups[0]).toMatchObject({
id: groupId,
expandStatus: "error",
hasMore: true
})
);
controller.expandGroup(groupId);
await vi.waitFor(() => expect(queryGroupPage).toHaveBeenCalledTimes(2));
retryPage.resolve({
id: "topic/one",
label: "topic/one",
items: [issue("issue-1", "topic/one"), issue("issue-2", "topic/one")],
totalCount: 2
});
await vi.waitFor(() =>
expect(states.at(-1)?.groups[0]).toMatchObject({
id: groupId,
expandStatus: "idle",
hasMore: false,
items: [
expect.objectContaining({ targetId: "issue-1" }),
expect.objectContaining({ targetId: "issue-2" })
]
})
);
expect(pageSignals.every((signal) => !signal.aborted)).toBe(true);
controller.close();
const restored = new AgentMentionSearchController({
contextMentionProviders: [provider]
});
const restoredStates: AgentMentionSearchState[] = [];
restored.subscribe((state) => restoredStates.push(state));
restored.setFilter("issue");
restored.updateQuery({ workspaceId: "workspace-1", query: "" });
expect(restoredStates.at(-1)?.groups[0]).toMatchObject({
label: "Pinned",
items: [
expect.objectContaining({ targetId: "issue-1" }),
expect.objectContaining({ targetId: "issue-2" })
]
});
await vi.waitFor(() => expect(queryGroups).toHaveBeenCalledTimes(2));
expect(restoredStates.at(-1)?.groups[0]?.items).toHaveLength(2);
refresh.resolve(
groupedResult([
{
id: "topic/one",
label: "Pinned refreshed",
items: [issue("issue-4", "topic/one")],
totalCount: 1
}
])
);
await vi.waitFor(() =>
expect(restoredStates.at(-1)?.groups[0]).toMatchObject({
label: "Pinned refreshed",
items: [expect.objectContaining({ targetId: "issue-4" })]
})
);
expect(queryGroups).toHaveBeenCalledTimes(2);
});
it("preserves a page that completes while stale browse data revalidates", async () => {
let now = 1_000;
const refresh = deferred<RichTextTriggerGroupedQueryResult<TestIssue>>();
const queryGroups = vi
.fn()
.mockResolvedValueOnce(
groupedResult([
{
id: "topic/one",
label: "Pinned",
items: [issue("issue-1", "topic/one")],
totalCount: 2,
nextCursor: "cursor-1"
}
])
)
.mockReturnValueOnce(refresh.promise);
const provider = groupedIssueProvider({
queryGroups,
queryGroupPage: vi.fn(async () => ({
id: "topic/one",
label: "Pinned",
items: [issue("issue-2", "topic/one")],
totalCount: 2
}))
});
const controller = new AgentMentionSearchController({
browseCacheTtlMs: 0,
contextMentionProviders: [provider],
diagnosticNow: () => now
});
const states: AgentMentionSearchState[] = [];
controller.subscribe((state) => states.push(state));
controller.setFilter("issue");
controller.updateQuery({ workspaceId: "workspace-1", query: "" });
await vi.waitFor(() => expect(states.at(-1)?.status).toBe("ready"));
controller.close();
now += 1;
controller.updateQuery({ workspaceId: "workspace-1", query: "" });
controller.setFilter("issue");
await vi.waitFor(() => expect(queryGroups).toHaveBeenCalledTimes(2));
controller.expandGroup("issue-topic:topic%2Fone");
await vi.waitFor(() =>
expect(states.at(-1)?.groups[0]?.items).toHaveLength(2)
);
refresh.resolve(
groupedResult([
{
id: "topic/one",
label: "Pinned refreshed",
items: [issue("issue-1", "topic/one")],
totalCount: 2,
nextCursor: "cursor-1"
}
])
);
await vi.waitFor(() =>
expect(states.at(-1)?.groups[0]).toMatchObject({
label: "Pinned refreshed",
hasMore: false,
items: [
expect.objectContaining({ targetId: "issue-1" }),
expect.objectContaining({ targetId: "issue-2" })
]
})
);
});
it("loads search pages with the active query and cursor while preserving stable entry keys", async () => {
vi.useFakeTimers();
const initialItems = Array.from({ length: 10 }, (_, index) =>
issue(`issue-${index + 1}`, "topic/one")
);
const queryGroups = vi.fn(async () =>
groupedResult([
{
id: "topic/one",
label: "Pinned",
items: initialItems,
totalCount: 25,
nextCursor: "cursor-1"
}
])
);
const queryGroupPage = vi.fn(
async ({ cursor }: RichTextTriggerGroupPageQueryInput) => ({
id: "topic/one",
label: "Pinned",
items: Array.from(
{ length: cursor === "cursor-1" ? 10 : 5 },
(_, index) =>
issue(
`issue-${index + (cursor === "cursor-1" ? 11 : 21)}`,
"topic/one"
)
),
totalCount: 25,
...(cursor === "cursor-1" ? { nextCursor: "cursor-2" } : {})
})
);
const controller = new AgentMentionSearchController({
contextMentionProviders: [
groupedIssueProvider({ queryGroups, queryGroupPage })
]
});
const states: AgentMentionSearchState[] = [];
controller.subscribe((state) => states.push(state));
controller.setFilter("issue");
controller.updateQuery({ workspaceId: "workspace-1", query: " needle " });
await vi.advanceTimersByTimeAsync(120);
await vi.waitFor(() => expect(states.at(-1)?.status).toBe("ready"));
const initialState = states.at(-1)!;
const initialGroup = initialState.groups[0]!;
const initialEntryKeys = flattenAgentMentionPaletteEntries(
initialState
).map((entry) => entry.key);
expect(mentionGroupExpandCount(initialGroup, "issue")).toBe(10);
controller.expandGroup("issue-topic:topic%2Fone");
await vi.waitFor(() => expect(queryGroupPage).toHaveBeenCalledTimes(1));
expect(queryGroupPage).toHaveBeenCalledWith(
expect.objectContaining({
keyword: "needle",
groupId: "topic/one",
cursor: "cursor-1",
pageSize: 10
})
);
await vi.waitFor(() =>
expect(states.at(-1)?.groups[0]?.visibleCount).toBe(20)
);
const pagedState = states.at(-1)!;
const pagedGroup = pagedState.groups[0]!;
const pagedEntryKeys = flattenAgentMentionPaletteEntries(pagedState).map(
(entry) => entry.key
);
expect(pagedGroup).toMatchObject({
totalCount: 25,
visibleCount: 20,
hasMore: true,
expandStatus: "idle"
});
expect(mentionGroupExpandCount(pagedGroup, "issue")).toBe(5);
expect(pagedEntryKeys.slice(0, initialItems.length)).toEqual(
initialEntryKeys.slice(0, initialItems.length)
);
expect(pagedEntryKeys.at(-1)).toBe("expand:issue-topic:topic%2Fone");
controller.expandGroup("issue-topic:topic%2Fone");
await vi.waitFor(() => expect(queryGroupPage).toHaveBeenCalledTimes(2));
expect(queryGroupPage).toHaveBeenLastCalledWith(
expect.objectContaining({
keyword: "needle",
groupId: "topic/one",
cursor: "cursor-2",
pageSize: 10
})
);
await vi.waitFor(() =>
expect(states.at(-1)?.groups[0]).toMatchObject({
totalCount: 25,
visibleCount: 25,
hasMore: false
})
);
});
it("cancels orphaned initial browse requests on every controller identity change", async () => {
const runCase = async (
suffix: string,
change: (controller: AgentMentionSearchController) => void
): Promise<void> => {
const signals: AbortSignal[] = [];
const provider = groupedIssueProvider({
queryGroups: vi.fn(
({ abortSignal }: RichTextTriggerQueryInput) =>
new Promise<RichTextTriggerGroupedQueryResult<TestIssue>>(
(_resolve, reject) => {
signals.push(abortSignal!);
abortSignal?.addEventListener(
"abort",
() => reject(abortError()),
{ once: true }
);
}
)
)
});
const controller = new AgentMentionSearchController({
contextMentionProviders: [provider]
});
controller.setFilter("issue");
controller.updateQuery({
workspaceId: `workspace-${suffix}`,
query: ""
});
await vi.waitFor(() => expect(signals).toHaveLength(1));
change(controller);
expect(signals[0]?.aborted).toBe(true);
controller.dispose();
resetAgentMentionSearchBrowseCacheForTests();
};
await runCase("workspace", (controller) =>
controller.updateQuery({ workspaceId: "workspace-next", query: "" })
);
await runCase("category", (controller) => controller.setFilter("app"));
await runCase("query", (controller) =>
controller.updateQuery({
workspaceId: "workspace-query",
query: "needle"
})
);
await runCase("close", (controller) => controller.close());
await runCase("dispose", (controller) => controller.dispose());
});
it("keeps a shared browse request alive until its final consumer closes", async () => {
let providerSignal: AbortSignal | undefined;
const queryGroups = vi.fn(
({ abortSignal }: RichTextTriggerQueryInput) =>
new Promise<RichTextTriggerGroupedQueryResult<TestIssue>>(
(_resolve, reject) => {
providerSignal = abortSignal;
abortSignal?.addEventListener("abort", () => reject(abortError()), {
once: true
});
}
)
);
const provider = groupedIssueProvider({ queryGroups });
const first = new AgentMentionSearchController({
contextMentionProviders: [provider]
});
const second = new AgentMentionSearchController({
contextMentionProviders: [provider]
});
for (const controller of [first, second]) {
controller.setFilter("issue");
controller.updateQuery({ workspaceId: "workspace-shared", query: "" });
}
await vi.waitFor(() => expect(queryGroups).toHaveBeenCalledTimes(1));
first.close();
expect(providerSignal?.aborted).toBe(false);
second.close();
expect(providerSignal?.aborted).toBe(true);
});
it("aborts a pending topic page when the search query changes and ignores its late result", async () => {
vi.useFakeTimers();
const page = deferred<{
id: string;
label: string;
items: TestIssue[];
totalCount: number;
}>();
let pageSignal: AbortSignal | undefined;
const provider = groupedIssueProvider({
queryGroups: vi.fn(async ({ keyword }: RichTextTriggerQueryInput) =>
groupedResult([
{
id: "topic/one",
label: "Pinned",
items: [issue(`${keyword || "browse"}-1`, "topic/one")],
totalCount: 2,
nextCursor: "cursor-1"
}
])
),
queryGroupPage: vi.fn((input) => {
pageSignal = input.abortSignal;
return page.promise;
})
});
const controller = new AgentMentionSearchController({
contextMentionProviders: [provider]
});
const states: AgentMentionSearchState[] = [];
controller.subscribe((state) => states.push(state));
controller.setFilter("issue");
controller.updateQuery({ workspaceId: "workspace-1", query: "" });
await vi.waitFor(() => expect(states.at(-1)?.status).toBe("ready"));
controller.expandGroup("issue-topic:topic%2Fone");
await vi.waitFor(() => expect(pageSignal?.aborted).toBe(false));
controller.updateQuery({ workspaceId: "workspace-1", query: "new" });
expect(pageSignal?.aborted).toBe(true);
await vi.advanceTimersByTimeAsync(120);
page.resolve({
id: "topic/one",
label: "Pinned",
items: [issue("late-2", "topic/one")],
totalCount: 2
});
await vi.waitFor(() =>
expect(states.at(-1)).toMatchObject({
status: "ready",
query: "new",
groups: [
{
items: [expect.objectContaining({ targetId: "new-1" })]
}
]
})
);
expect(
states
.at(-1)
?.groups[0]?.items.some(
(item) =>
item.kind === "workspace-issue" && item.targetId === "late-2"
)
).toBe(false);
});
it("surfaces an initial grouped provider failure as the existing global error state", async () => {
const controller = new AgentMentionSearchController({
contextMentionProviders: [
groupedIssueProvider({
queryGroups: vi.fn().mockRejectedValue(new Error("topics failed"))
})
]
});
const states: AgentMentionSearchState[] = [];
controller.subscribe((state) => states.push(state));
controller.setFilter("issue");
controller.updateQuery({ workspaceId: "workspace-1", query: "" });
await vi.waitFor(() =>
expect(states.at(-1)).toMatchObject({
status: "error",
groups: [],
error: "topics failed"
})
);
});
});