Files
tutti/packages/agent/gui/agent-gui/agentGuiNode/composer/quickPrompts/useAgentQuickPromptLibrary.spec.tsx
T
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

611 lines
19 KiB
TypeScript

import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
AgentHostQuickPromptSnapshot,
AgentHostQuickPromptsApi,
AgentHostRuntimeApi
} from "../../../../host/agentHostApi";
import type { AgentQuickPromptLabels } from "./agentQuickPromptLabels";
import { useAgentQuickPromptLibrary } from "./useAgentQuickPromptLibrary";
let hostApi: AgentHostRuntimeApi | null = null;
vi.mock("../../../../agentActivityHost", () => ({
useOptionalAgentHostApi: () => hostApi
}));
const labels = new Proxy(
{ deleteDescription: (title: string) => title },
{
get: (target, property) => Reflect.get(target, property) ?? String(property)
}
) as AgentQuickPromptLabels;
function createQuickPrompts() {
let snapshot: AgentHostQuickPromptSnapshot = {
enabled: true,
status: "idle",
prompts: [
{
id: "prompt-1",
title: "Review",
content: "Review this change",
version: 1,
createdAtUnixMs: 1,
updatedAtUnixMs: 2
}
],
error: null,
revision: 0,
pendingMutationIds: []
};
const listeners = new Set<(snapshot: AgentHostQuickPromptSnapshot) => void>();
const api: AgentHostQuickPromptsApi = {
ensureLoaded: vi.fn(async () => {}),
getSnapshot: () => snapshot,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
create: vi.fn(async (input) => ({
id: "created",
...input,
version: 1,
createdAtUnixMs: 3,
updatedAtUnixMs: 3
})),
update: vi.fn(async (input) => ({
id: input.id,
title: input.title,
content: input.content,
version: input.expectedVersion + 1,
createdAtUnixMs: 1,
updatedAtUnixMs: 4
})),
remove: vi.fn(async () => {}),
move: vi.fn(async () => snapshot.prompts)
};
return {
api,
publish(next: AgentHostQuickPromptSnapshot) {
snapshot = next;
for (const listener of listeners) listener(snapshot);
},
snapshot: () => snapshot
};
}
describe("useAgentQuickPromptLibrary", () => {
beforeEach(() => {
hostApi = null;
});
it("preserves canonical host order and sends an anchor move", async () => {
const quickPrompts = createQuickPrompts();
const older = {
...quickPrompts.snapshot().prompts[0]!,
id: "older",
title: "Older",
updatedAtUnixMs: 1
};
const newer = {
...older,
id: "newer",
title: "Newer",
updatedAtUnixMs: 99
};
quickPrompts.publish({
...quickPrompts.snapshot(),
status: "ready",
prompts: [older, newer],
revision: 1
});
hostApi = { quickPrompts: quickPrompts.api } as AgentHostRuntimeApi;
const rendered = renderHook(() =>
useAgentQuickPromptLibrary({
disabled: false,
labels,
onBeforeOpen: vi.fn(),
onInsertPrompt: vi.fn()
})
);
expect(
rendered.result.current.filteredPrompts.map((prompt) => prompt.id)
).toEqual(["older", "newer"]);
let moved = false;
await act(async () => {
moved = await rendered.result.current.reorderPrompts("older", null);
});
expect(moved).toBe(true);
expect(quickPrompts.api.move).toHaveBeenCalledWith({
promptId: "older",
beforePromptId: null,
expectedVersion: older.version
});
});
it("locks reorder and all mutating entry points while the shared host snapshot is pending", () => {
const quickPrompts = createQuickPrompts();
const second = {
...quickPrompts.snapshot().prompts[0]!,
id: "prompt-2",
title: "Plan"
};
quickPrompts.publish({
...quickPrompts.snapshot(),
status: "ready",
prompts: [...quickPrompts.snapshot().prompts, second],
pendingMutationIds: ["other-window-update"],
revision: 1
});
hostApi = { quickPrompts: quickPrompts.api } as AgentHostRuntimeApi;
const onInsertPrompt = vi.fn(() => true);
const rendered = renderHook(() =>
useAgentQuickPromptLibrary({
disabled: false,
labels,
onBeforeOpen: vi.fn(),
onInsertPrompt
})
);
expect(rendered.result.current.isInteractionLocked).toBe(true);
expect(rendered.result.current.canReorder).toBe(false);
expect(rendered.result.current.reorderCapabilityAvailable).toBe(true);
act(() => {
rendered.result.current.openCreate();
rendered.result.current.openEdit(second);
rendered.result.current.deletePrompt(second);
rendered.result.current.selectPrompt(second);
rendered.result.current.setSearchQuery("plan");
});
expect(rendered.result.current.mode).toBe("closed");
expect(rendered.result.current.searchQuery).toBe("");
expect(onInsertPrompt).not.toHaveBeenCalled();
});
it("lazy-loads when opened and inserts a selection without a submit path", () => {
const quickPrompts = createQuickPrompts();
hostApi = { quickPrompts: quickPrompts.api } as AgentHostRuntimeApi;
const onBeforeOpen = vi.fn();
const onInsertPrompt = vi.fn(() => true);
const rendered = renderHook(() =>
useAgentQuickPromptLibrary({
disabled: false,
labels,
onBeforeOpen,
onInsertPrompt
})
);
act(() => rendered.result.current.openPopover());
expect(onBeforeOpen).toHaveBeenCalledOnce();
expect(quickPrompts.api.ensureLoaded).toHaveBeenCalledWith();
expect(rendered.result.current.isPopoverOpen).toBe(true);
act(() =>
rendered.result.current.selectPrompt(
rendered.result.current.filteredPrompts[0]!
)
);
expect(onInsertPrompt).toHaveBeenCalledWith("Review this change");
expect(rendered.result.current.mode).toBe("closed");
});
it("closes all disclosure when the feature gate turns off", () => {
const quickPrompts = createQuickPrompts();
hostApi = { quickPrompts: quickPrompts.api } as AgentHostRuntimeApi;
const rendered = renderHook(() =>
useAgentQuickPromptLibrary({
disabled: false,
labels,
onBeforeOpen: vi.fn(),
onInsertPrompt: vi.fn()
})
);
act(() => rendered.result.current.openPopover());
act(() => rendered.result.current.openCreate());
expect(rendered.result.current.isEditorOpen).toBe(true);
act(() => {
quickPrompts.publish({
...quickPrompts.snapshot(),
enabled: false,
revision: 1
});
});
expect(rendered.result.current.capabilityAvailable).toBe(false);
expect(rendered.result.current.mode).toBe("closed");
});
it("does not let a stale popover close callback dismiss the create dialog", () => {
const quickPrompts = createQuickPrompts();
hostApi = { quickPrompts: quickPrompts.api } as AgentHostRuntimeApi;
const rendered = renderHook(() =>
useAgentQuickPromptLibrary({
disabled: false,
labels,
onBeforeOpen: vi.fn(),
onInsertPrompt: vi.fn()
})
);
act(() => rendered.result.current.openPopover());
const stalePopoverOpenChange = rendered.result.current.setPopoverOpen;
act(() => rendered.result.current.openCreate());
expect(rendered.result.current.mode).toBe("create");
act(() => stalePopoverOpenChange(false));
expect(rendered.result.current.mode).toBe("create");
expect(rendered.result.current.isEditorOpen).toBe(true);
});
it("does not open or load while composer controls are disabled", () => {
const quickPrompts = createQuickPrompts();
hostApi = { quickPrompts: quickPrompts.api } as AgentHostRuntimeApi;
const rendered = renderHook(() =>
useAgentQuickPromptLibrary({
disabled: true,
labels,
onBeforeOpen: vi.fn(),
onInsertPrompt: vi.fn()
})
);
act(() => rendered.result.current.openPopover());
expect(rendered.result.current.mode).toBe("closed");
expect(quickPrompts.api.ensureLoaded).not.toHaveBeenCalled();
});
it("keeps a recommended template local until the user saves it", () => {
const quickPrompts = createQuickPrompts();
hostApi = { quickPrompts: quickPrompts.api } as AgentHostRuntimeApi;
const rendered = renderHook(() =>
useAgentQuickPromptLibrary({
disabled: false,
labels,
onBeforeOpen: vi.fn(),
onInsertPrompt: vi.fn()
})
);
const template = {
title: "Understand the situation",
content: "Summarize the situation"
};
act(() => rendered.result.current.openCreate(template));
expect(rendered.result.current.initialDraft).toEqual(template);
expect(rendered.result.current.mode).toBe("create");
expect(quickPrompts.api.create).not.toHaveBeenCalled();
act(() => rendered.result.current.closeDialog());
expect(rendered.result.current.initialDraft).toBeNull();
expect(quickPrompts.api.create).not.toHaveBeenCalled();
});
it("runs create and preserves edit disclosure on a version conflict", async () => {
const quickPrompts = createQuickPrompts();
hostApi = { quickPrompts: quickPrompts.api } as AgentHostRuntimeApi;
const onInsertPrompt = vi.fn(() => true);
const rendered = renderHook(() =>
useAgentQuickPromptLibrary({
disabled: false,
labels,
onBeforeOpen: vi.fn(),
onInsertPrompt
})
);
act(() => rendered.result.current.openPopover());
act(() => rendered.result.current.openCreate());
await act(async () => {
expect(
await rendered.result.current.saveDraft({
title: "Explain",
content: "Explain this code"
})
).toBe(true);
});
expect(quickPrompts.api.create).toHaveBeenCalledWith({
title: "Explain",
content: "Explain this code"
});
expect(rendered.result.current.mode).toBe("popover");
expect(onInsertPrompt).not.toHaveBeenCalled();
act(() =>
rendered.result.current.openEdit(
rendered.result.current.filteredPrompts[0]!
)
);
vi.mocked(quickPrompts.api.update).mockRejectedValueOnce({
code: "agent_quick_prompt_conflict",
reason: "agent_quick_prompt_version_conflict",
statusCode: 409
});
vi.mocked(quickPrompts.api.ensureLoaded).mockImplementationOnce(
async () => {
quickPrompts.publish({
...quickPrompts.snapshot(),
prompts: quickPrompts.snapshot().prompts.map((prompt) => ({
...prompt,
version: 2,
updatedAtUnixMs: 5
})),
revision: 2
});
}
);
await act(async () => {
expect(
await rendered.result.current.saveDraft({
title: "Review",
content: "Keep my changed draft"
})
).toBe(false);
});
expect(rendered.result.current.mode).toBe("edit");
expect(rendered.result.current.mutationError).toBe("conflict");
expect(quickPrompts.api.ensureLoaded).toHaveBeenLastCalledWith({
force: true
});
expect(rendered.result.current.selectedPrompt?.version).toBe(2);
await act(async () => {
expect(
await rendered.result.current.saveDraft({
title: "Review",
content: "Keep my changed draft"
})
).toBe(true);
});
expect(quickPrompts.api.update).toHaveBeenLastCalledWith(
expect.objectContaining({ expectedVersion: 2 })
);
expect(onInsertPrompt).not.toHaveBeenCalled();
});
it("inserts a template-backed prompt into the Composer after creation", async () => {
const quickPrompts = createQuickPrompts();
hostApi = { quickPrompts: quickPrompts.api } as AgentHostRuntimeApi;
const onInsertPrompt = vi.fn(() => true);
const onQuickPromptUsed = vi.fn();
const rendered = renderHook(() =>
useAgentQuickPromptLibrary({
disabled: false,
labels,
onBeforeOpen: vi.fn(),
onInsertPrompt,
onQuickPromptUsed
})
);
act(() => rendered.result.current.openPopover());
act(() =>
rendered.result.current.openCreate(
{
title: "Template title",
content: "Template content"
},
{
insertIntoComposerAfterSave: true,
usagePromptType: "recommended_template"
}
)
);
await act(async () => {
expect(
await rendered.result.current.saveDraft({
title: "Edited title",
content: "Edited template content"
})
).toBe(true);
});
expect(quickPrompts.api.create).toHaveBeenCalledWith({
title: "Edited title",
content: "Edited template content"
});
expect(onInsertPrompt).toHaveBeenCalledOnce();
expect(onInsertPrompt).toHaveBeenCalledWith("Edited template content");
expect(onQuickPromptUsed).toHaveBeenCalledOnce();
expect(onQuickPromptUsed).toHaveBeenCalledWith("recommended_template");
expect(rendered.result.current.mode).toBe("closed");
});
it("keeps a saved template in the library when Composer insertion throws", async () => {
const quickPrompts = createQuickPrompts();
hostApi = { quickPrompts: quickPrompts.api } as AgentHostRuntimeApi;
const onInsertPrompt = vi.fn(() => {
throw new Error("editor unavailable");
});
const onQuickPromptUsed = vi.fn();
const rendered = renderHook(() =>
useAgentQuickPromptLibrary({
disabled: false,
labels,
onBeforeOpen: vi.fn(),
onInsertPrompt,
onQuickPromptUsed
})
);
act(() => rendered.result.current.openPopover());
act(() =>
rendered.result.current.openCreate(
{ title: "Template", content: "Template content" },
{
insertIntoComposerAfterSave: true,
usagePromptType: "recommended_template"
}
)
);
await act(async () => {
expect(
await rendered.result.current.saveDraft({
title: "Template",
content: "Edited content"
})
).toBe(true);
});
expect(quickPrompts.api.create).toHaveBeenCalledOnce();
expect(onInsertPrompt).toHaveBeenCalledWith("Edited content");
expect(rendered.result.current.mutationError).toBeNull();
expect(rendered.result.current.insertionError).toBe(true);
expect(onQuickPromptUsed).not.toHaveBeenCalled();
expect(rendered.result.current.mode).toBe("popover");
});
it("retains template insertion intent when creation succeeds on retry", async () => {
const quickPrompts = createQuickPrompts();
vi.mocked(quickPrompts.api.create).mockRejectedValueOnce(
new Error("temporary create failure")
);
hostApi = { quickPrompts: quickPrompts.api } as AgentHostRuntimeApi;
const onInsertPrompt = vi.fn(() => true);
const onQuickPromptUsed = vi.fn();
const rendered = renderHook(() =>
useAgentQuickPromptLibrary({
disabled: false,
labels,
onBeforeOpen: vi.fn(),
onInsertPrompt,
onQuickPromptUsed
})
);
act(() => rendered.result.current.openPopover());
act(() =>
rendered.result.current.openCreate(
{ title: "Template", content: "Template content" },
{
insertIntoComposerAfterSave: true,
usagePromptType: "recommended_template"
}
)
);
await act(async () => {
expect(
await rendered.result.current.saveDraft({
title: "Template",
content: "Edited content"
})
).toBe(false);
});
expect(onInsertPrompt).not.toHaveBeenCalled();
expect(onQuickPromptUsed).not.toHaveBeenCalled();
expect(rendered.result.current.mode).toBe("create");
await act(async () => {
expect(
await rendered.result.current.saveDraft({
title: "Template",
content: "Edited again"
})
).toBe(true);
});
expect(onInsertPrompt).toHaveBeenCalledOnce();
expect(onQuickPromptUsed).toHaveBeenCalledOnce();
expect(onQuickPromptUsed).toHaveBeenCalledWith("recommended_template");
expect(onInsertPrompt).toHaveBeenCalledWith("Edited again");
expect(rendered.result.current.mode).toBe("closed");
});
it("does not insert after the template creation disclosure becomes unavailable", async () => {
const quickPrompts = createQuickPrompts();
let releaseCreate: (() => void) | undefined;
vi.mocked(quickPrompts.api.create).mockImplementationOnce(async (input) => {
await new Promise<void>((resolve) => {
releaseCreate = resolve;
});
return {
id: "created-after-disable",
...input,
version: 1,
createdAtUnixMs: 5,
updatedAtUnixMs: 5
};
});
hostApi = { quickPrompts: quickPrompts.api } as AgentHostRuntimeApi;
const onInsertPrompt = vi.fn(() => true);
const rendered = renderHook(
({ disabled }) =>
useAgentQuickPromptLibrary({
disabled,
labels,
onBeforeOpen: vi.fn(),
onInsertPrompt
}),
{ initialProps: { disabled: false } }
);
act(() => rendered.result.current.openPopover());
act(() =>
rendered.result.current.openCreate(
{ title: "Template", content: "Template content" },
{ insertIntoComposerAfterSave: true }
)
);
let saveResult: Promise<boolean> | undefined;
act(() => {
saveResult = rendered.result.current.saveDraft({
title: "Template",
content: "Edited content"
});
});
expect(releaseCreate).toBeTypeOf("function");
rendered.rerender({ disabled: true });
await act(async () => {
releaseCreate?.();
expect(await saveResult).toBe(true);
});
expect(onInsertPrompt).not.toHaveBeenCalled();
expect(rendered.result.current.mode).toBe("closed");
});
it("does not treat the prompt limit as a version conflict", async () => {
const quickPrompts = createQuickPrompts();
hostApi = { quickPrompts: quickPrompts.api } as AgentHostRuntimeApi;
vi.mocked(quickPrompts.api.create).mockRejectedValueOnce({
code: "agent_quick_prompt_conflict",
reason: "agent_quick_prompt_limit_exceeded",
statusCode: 409
});
const onInsertPrompt = vi.fn(() => true);
const rendered = renderHook(() =>
useAgentQuickPromptLibrary({
disabled: false,
labels,
onBeforeOpen: vi.fn(),
onInsertPrompt
})
);
act(() => rendered.result.current.openPopover());
act(() =>
rendered.result.current.openCreate(
{ title: "Template", content: "Template content" },
{ insertIntoComposerAfterSave: true }
)
);
await act(async () => {
expect(
await rendered.result.current.saveDraft({
title: "One more prompt",
content: "This exceeds the device limit"
})
).toBe(false);
});
expect(rendered.result.current.mutationError).toBe("generic");
expect(rendered.result.current.insertionError).toBe(false);
expect(rendered.result.current.mode).toBe("create");
expect(onInsertPrompt).not.toHaveBeenCalled();
expect(quickPrompts.api.ensureLoaded).toHaveBeenCalledTimes(1);
});
});