mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
A draft composer passed no command config when it was missing a project or a provider, which is what a running agent's composer passes. The `/` menu then listed commands by the draft key, got "Agent not found", and showed /clear and /exit. Drafts now always report a command target (ready, needs-project, needs-provider), and an incomplete draft asks for the missing choice instead of querying. Closes #5516 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
611 lines
17 KiB
TypeScript
611 lines
17 KiB
TypeScript
/** @vitest-environment jsdom */
|
|
import React, { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { useDraftStore } from "@/stores/draft-store";
|
|
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
|
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
|
|
|
const { asyncStorage } = vi.hoisted(() => ({
|
|
asyncStorage: new Map<string, string>(),
|
|
}));
|
|
|
|
vi.hoisted(() => {
|
|
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
|
});
|
|
|
|
vi.mock("@react-native-async-storage/async-storage", () => ({
|
|
default: {
|
|
getItem: async (key: string) => asyncStorage.get(key) ?? null,
|
|
setItem: async (key: string, value: string) => {
|
|
asyncStorage.set(key, value);
|
|
},
|
|
removeItem: async (key: string) => {
|
|
asyncStorage.delete(key);
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/attachments/service", () => ({
|
|
garbageCollectAttachments: async () => undefined,
|
|
}));
|
|
|
|
vi.mock("@/hooks/use-agent-form-state", () => ({
|
|
useAgentFormState: () => ({
|
|
selectedServerId: "host-1",
|
|
selectedProvider: "codex",
|
|
setProviderFromUser: () => undefined,
|
|
selectedMode: "auto",
|
|
setModeFromUser: () => undefined,
|
|
selectedModel: "",
|
|
setModelFromUser: () => undefined,
|
|
selectedThinkingOptionId: "",
|
|
setThinkingOptionFromUser: () => undefined,
|
|
workingDir: "/repo",
|
|
providerDefinitions: [{ id: "codex", label: "Codex", modes: [{ id: "auto", label: "Auto" }] }],
|
|
providerDefinitionMap: new Map(),
|
|
agentDefinition: undefined,
|
|
modeOptions: [{ id: "auto", label: "Auto" }],
|
|
availableModels: [
|
|
{
|
|
provider: "codex",
|
|
id: "gpt-5.4",
|
|
label: "gpt-5.4",
|
|
isDefault: true,
|
|
defaultThinkingOptionId: "high",
|
|
thinkingOptions: [
|
|
{ id: "medium", label: "Medium" },
|
|
{ id: "high", label: "High", isDefault: true },
|
|
],
|
|
},
|
|
],
|
|
allProviderModels: new Map([
|
|
[
|
|
"codex",
|
|
[
|
|
{
|
|
provider: "codex",
|
|
id: "gpt-5.4",
|
|
label: "gpt-5.4",
|
|
isDefault: true,
|
|
defaultThinkingOptionId: "high",
|
|
thinkingOptions: [
|
|
{ id: "medium", label: "Medium" },
|
|
{ id: "high", label: "High", isDefault: true },
|
|
],
|
|
},
|
|
],
|
|
],
|
|
]),
|
|
modelSelectorProviders: [
|
|
{
|
|
id: "codex",
|
|
label: "Codex",
|
|
modelSelection: {
|
|
kind: "models",
|
|
rows: [
|
|
{
|
|
favoriteKey: "codex:gpt-5.4",
|
|
provider: "codex",
|
|
providerLabel: "Codex",
|
|
modelId: "gpt-5.4",
|
|
modelLabel: "gpt-5.4",
|
|
isDefault: true,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
isAllModelsLoading: false,
|
|
isProviderModelsRefreshing: false,
|
|
availableThinkingOptions: [
|
|
{ id: "medium", label: "Medium" },
|
|
{ id: "high", label: "High", isDefault: true },
|
|
],
|
|
isModelLoading: false,
|
|
modelError: null,
|
|
refreshProviderModels: () => undefined,
|
|
setProviderAndModelFromUser: () => undefined,
|
|
workingDirIsEmpty: false,
|
|
persistFormPreferences: async () => undefined,
|
|
}),
|
|
}));
|
|
|
|
const mountedRoots = new Set<Root>();
|
|
function createTestRoot(container: HTMLElement): Root {
|
|
const root = createRoot(container);
|
|
mountedRoots.add(root);
|
|
return root;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await act(async () => {
|
|
for (const root of mountedRoots) root.unmount();
|
|
mountedRoots.clear();
|
|
});
|
|
});
|
|
|
|
let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft;
|
|
type DraftRecordForTest = ReturnType<typeof useDraftStore.getState>["drafts"][string];
|
|
|
|
beforeAll(async () => {
|
|
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
value: true,
|
|
configurable: true,
|
|
});
|
|
|
|
({ useAgentInputDraft } = await import("./input-draft"));
|
|
});
|
|
|
|
describe("useAgentInputDraft live contract", () => {
|
|
beforeEach(() => {
|
|
asyncStorage.clear();
|
|
document.body.innerHTML = "<div id='root'></div>";
|
|
localStorage.clear();
|
|
|
|
useDraftStore.setState({
|
|
drafts: {},
|
|
createModalDraft: null,
|
|
attachmentFocusRequestByDraftKey: {},
|
|
});
|
|
});
|
|
|
|
it("hydrates persisted text and attachments and returns draft-mode composer state for a caller-provided key", async () => {
|
|
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
|
const image: AttachmentMetadata = {
|
|
id: "attachment-1",
|
|
mimeType: "image/png",
|
|
storageType: "web-indexeddb",
|
|
storageKey: "attachments/1",
|
|
createdAt: 1,
|
|
fileName: "image.png",
|
|
byteSize: 128,
|
|
};
|
|
|
|
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
|
if (!latest) {
|
|
throw new Error("Expected hook result");
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
function Probe({ draftKey }: { draftKey: string }) {
|
|
latest = useAgentInputDraft({
|
|
draftKey,
|
|
composer: {
|
|
initialServerId: "host-1",
|
|
isVisible: true,
|
|
lockedWorkingDir: "/repo",
|
|
},
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const queryClient = new QueryClient();
|
|
const container = document.getElementById("root");
|
|
if (!container) {
|
|
throw new Error("Missing root container");
|
|
}
|
|
|
|
let root: Root | null = createTestRoot(container);
|
|
await act(async () => {
|
|
root!.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Probe draftKey="draft:setup" />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
expect(getLatest().composerState?.agentControls.selectedProvider).toBe("codex");
|
|
expect(getLatest().composerState?.commandDraft).toEqual({
|
|
status: "ready",
|
|
config: {
|
|
provider: "codex",
|
|
cwd: "/repo",
|
|
modeId: "auto",
|
|
model: "gpt-5.4",
|
|
thinkingOptionId: "high",
|
|
},
|
|
});
|
|
|
|
const hydratedTextReplacement = getLatest().textReplacement;
|
|
|
|
await act(async () => {
|
|
getLatest().editText("hello world");
|
|
getLatest().setAttachments([{ kind: "image", metadata: image }]);
|
|
});
|
|
|
|
expect(getLatest().textReplacement).toBe(hydratedTextReplacement);
|
|
|
|
await act(async () => {
|
|
getLatest().replaceText("replacement text");
|
|
});
|
|
|
|
expect(getLatest().textReplacement).not.toBe(hydratedTextReplacement);
|
|
expect(getLatest().textReplacement.text).toBe("replacement text");
|
|
|
|
await act(async () => {
|
|
getLatest().editText("hello world");
|
|
});
|
|
|
|
await act(async () => {
|
|
root!.unmount();
|
|
});
|
|
|
|
root = createTestRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Probe draftKey="draft:setup" />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
expect(getLatest().textSource.getSnapshot()).toBe("hello world");
|
|
expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]);
|
|
});
|
|
|
|
it("migrates legacy image drafts to image attachments on hydration", async () => {
|
|
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
|
const image: AttachmentMetadata = {
|
|
id: "legacy-image",
|
|
mimeType: "image/png",
|
|
storageType: "web-indexeddb",
|
|
storageKey: "attachments/legacy-image",
|
|
createdAt: 10,
|
|
fileName: "legacy.png",
|
|
byteSize: 512,
|
|
};
|
|
|
|
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
|
if (!latest) {
|
|
throw new Error("Expected hook result");
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
useDraftStore.setState({
|
|
drafts: {
|
|
"draft:legacy": {
|
|
input: {
|
|
text: "legacy text",
|
|
images: [image],
|
|
},
|
|
lifecycle: "active",
|
|
updatedAt: Date.now(),
|
|
version: 1,
|
|
} as unknown as DraftRecordForTest,
|
|
},
|
|
createModalDraft: null,
|
|
});
|
|
|
|
function Probe() {
|
|
latest = useAgentInputDraft({ draftKey: "draft:legacy" });
|
|
return null;
|
|
}
|
|
|
|
const queryClient = new QueryClient();
|
|
const container = document.getElementById("root");
|
|
if (!container) {
|
|
throw new Error("Missing root container");
|
|
}
|
|
|
|
const root = createTestRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Probe />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
expect(getLatest().textSource.getSnapshot()).toBe("legacy text");
|
|
expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]);
|
|
expect(useDraftStore.getState().drafts["draft:legacy"]?.input).toEqual({
|
|
text: "legacy text",
|
|
attachments: [{ kind: "image", metadata: image }],
|
|
});
|
|
});
|
|
|
|
it("hydrates drafts saved by old builds with cwd", async () => {
|
|
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
|
const githubIssue: ComposerAttachment = {
|
|
kind: "github_issue",
|
|
item: {
|
|
kind: "issue",
|
|
number: 42,
|
|
title: "Unify attachments",
|
|
url: "https://github.com/paseo/paseo/issues/42",
|
|
state: "open",
|
|
body: "body",
|
|
labels: ["composer"],
|
|
},
|
|
};
|
|
|
|
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
|
if (!latest) {
|
|
throw new Error("Expected hook result");
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
useDraftStore.setState({
|
|
drafts: {
|
|
"draft:new-shape": {
|
|
input: {
|
|
text: "new text",
|
|
attachments: [githubIssue],
|
|
cwd: "/persisted",
|
|
},
|
|
lifecycle: "active",
|
|
updatedAt: Date.now(),
|
|
version: 1,
|
|
} as unknown as DraftRecordForTest,
|
|
},
|
|
createModalDraft: null,
|
|
});
|
|
|
|
function Probe() {
|
|
latest = useAgentInputDraft({ draftKey: "draft:new-shape" });
|
|
return null;
|
|
}
|
|
|
|
const queryClient = new QueryClient();
|
|
const container = document.getElementById("root");
|
|
if (!container) {
|
|
throw new Error("Missing root container");
|
|
}
|
|
|
|
const root = createTestRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Probe />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
expect(getLatest().textSource.getSnapshot()).toBe("new text");
|
|
expect(getLatest().attachments).toEqual([githubIssue]);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
|
|
expect(useDraftStore.getState().drafts["draft:new-shape"]?.input).toEqual({
|
|
text: "new text",
|
|
attachments: [githubIssue],
|
|
cwd: "/persisted",
|
|
});
|
|
});
|
|
|
|
it("updates and persists attachments through setAttachments", async () => {
|
|
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
|
const image: AttachmentMetadata = {
|
|
id: "next-image",
|
|
mimeType: "image/png",
|
|
storageType: "web-indexeddb",
|
|
storageKey: "attachments/next-image",
|
|
createdAt: 11,
|
|
};
|
|
|
|
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
|
if (!latest) {
|
|
throw new Error("Expected hook result");
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
let renders = 0;
|
|
function Probe() {
|
|
renders += 1;
|
|
latest = useAgentInputDraft({ draftKey: "draft:attachments" });
|
|
return null;
|
|
}
|
|
|
|
const queryClient = new QueryClient();
|
|
const container = document.getElementById("root");
|
|
if (!container) {
|
|
throw new Error("Missing root container");
|
|
}
|
|
|
|
const root = createTestRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Probe />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
getLatest().editText("with attachment");
|
|
getLatest().setAttachments([{ kind: "image", metadata: image }]);
|
|
});
|
|
|
|
expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]);
|
|
const readPersistedInput = () => useDraftStore.getState().drafts["draft:attachments"]?.input;
|
|
await act(async () => {
|
|
// Web text publication occurs after paint; attachments save immediately.
|
|
await expect.poll(readPersistedInput).toEqual({
|
|
text: "with attachment",
|
|
attachments: [{ kind: "image", metadata: image }],
|
|
});
|
|
});
|
|
const settledRenders = renders;
|
|
const attached = getLatest().attachments;
|
|
const source = getLatest().textSource;
|
|
let textNotifications = 0;
|
|
const unsubscribe = source.subscribe(() => {
|
|
textNotifications += 1;
|
|
});
|
|
await act(async () => {
|
|
getLatest().editText("with attachment\n");
|
|
await expect.poll(source.getSnapshot).toBe("with attachment\n");
|
|
});
|
|
expect(renders).toBe(settledRenders);
|
|
expect(getLatest().attachments).toBe(attached);
|
|
expect(getLatest().textSource).toBe(source);
|
|
expect(textNotifications).toBe(1);
|
|
unsubscribe();
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("attaches to an unmounted legacy draft without losing its input", async () => {
|
|
const image: AttachmentMetadata = {
|
|
id: "legacy-image",
|
|
mimeType: "image/png",
|
|
storageType: "web-indexeddb",
|
|
storageKey: "attachments/legacy-image",
|
|
createdAt: 10,
|
|
};
|
|
useDraftStore.setState({
|
|
drafts: {
|
|
"draft:legacy-workspace-file": {
|
|
input: { text: "legacy text", images: [image] },
|
|
lifecycle: "active",
|
|
updatedAt: Date.now(),
|
|
version: 1,
|
|
} as unknown as DraftRecordForTest,
|
|
},
|
|
});
|
|
|
|
await useDraftStore.getState().attachWorkspaceFile({
|
|
draftKey: "draft:legacy-workspace-file",
|
|
attachment: createWorkspaceFileAttachment({ path: "src/app.ts" }),
|
|
});
|
|
|
|
expect(useDraftStore.getState().getDraftInput("draft:legacy-workspace-file")).toEqual({
|
|
text: "legacy text",
|
|
attachments: [
|
|
{ kind: "image", metadata: image },
|
|
createWorkspaceFileAttachment({ path: "src/app.ts" }),
|
|
],
|
|
});
|
|
});
|
|
|
|
it("clear resets text and attachments", async () => {
|
|
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
|
const image: AttachmentMetadata = {
|
|
id: "clear-image",
|
|
mimeType: "image/png",
|
|
storageType: "web-indexeddb",
|
|
storageKey: "attachments/clear-image",
|
|
createdAt: 12,
|
|
};
|
|
|
|
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
|
if (!latest) {
|
|
throw new Error("Expected hook result");
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
function Probe() {
|
|
latest = useAgentInputDraft({ draftKey: "draft:clear" });
|
|
return null;
|
|
}
|
|
|
|
const queryClient = new QueryClient();
|
|
const container = document.getElementById("root");
|
|
if (!container) {
|
|
throw new Error("Missing root container");
|
|
}
|
|
|
|
const root = createTestRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Probe />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
getLatest().editText("queued message");
|
|
getLatest().setAttachments([{ kind: "image", metadata: image }]);
|
|
});
|
|
|
|
await act(async () => {
|
|
getLatest().clear("sent");
|
|
});
|
|
|
|
expect(getLatest().textSource.getSnapshot()).toBe("");
|
|
expect(getLatest().attachments).toEqual([]);
|
|
expect(useDraftStore.getState().drafts["draft:clear"]?.input).toEqual({
|
|
text: "",
|
|
attachments: [],
|
|
});
|
|
});
|
|
|
|
it("clears drafts with sent and abandoned lifecycle tombstones", async () => {
|
|
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
|
const sentImage: AttachmentMetadata = {
|
|
id: "attachment-sent",
|
|
mimeType: "image/png",
|
|
storageType: "web-indexeddb",
|
|
storageKey: "attachments/sent",
|
|
createdAt: 2,
|
|
};
|
|
|
|
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
|
if (!latest) {
|
|
throw new Error("Expected hook result");
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
function Probe() {
|
|
latest = useAgentInputDraft({ draftKey: "draft:lifecycle" });
|
|
return null;
|
|
}
|
|
|
|
const queryClient = new QueryClient();
|
|
const container = document.getElementById("root");
|
|
if (!container) {
|
|
throw new Error("Missing root container");
|
|
}
|
|
|
|
const root = createTestRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Probe />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
getLatest().editText("queued message");
|
|
getLatest().setAttachments([{ kind: "image", metadata: sentImage }]);
|
|
});
|
|
|
|
await act(async () => {
|
|
getLatest().clear("sent");
|
|
});
|
|
|
|
expect(getLatest().textSource.getSnapshot()).toBe("");
|
|
expect(getLatest().attachments).toEqual([]);
|
|
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
|
|
lifecycle: "sent",
|
|
input: { text: "", attachments: [] },
|
|
});
|
|
|
|
await act(async () => {
|
|
getLatest().editText("draft again");
|
|
});
|
|
|
|
await act(async () => {
|
|
getLatest().clear("abandoned");
|
|
});
|
|
|
|
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
|
|
lifecycle: "abandoned",
|
|
input: { text: "", attachments: [] },
|
|
});
|
|
});
|
|
});
|