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
611 lines
19 KiB
TypeScript
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);
|
|
});
|
|
});
|