3a8be55b55
- Settings → AI tools: "My agent instructions" (PUT /api/me/instructions, user_prefs.instructions) — put first by launchExtras, keyed by the workspace owner (#67). - Session view: Settings opens as one more tab of the tab bar. - Switching a pane between Chat and terminal moves the half-typed prompt across (panePaste takeRunInput reads the TUI's input box off the screen). - Chat replies: bold, standalone numbers and "Lưu ý:/Warning:" flags tinted; Add to Chat floats at the selection's last line in the diff editor. - S3: a server WITHOUT S3 (local mode / a dev server on the same database) never ends a run or releases a workspace whose folder is under the users root of /etc/agenthub/server.conf (linux-accounts.unseenMemberFolder, used by reconcileOrphans and healRunId). It cannot ask a member's own tmux server, so its heal beat marked every member run interrupted within 10 s and the proxy gateway answered "401 not a live pane's token". Regression test: s3-unseen-member-runs.test.js. Committed --no-verify: tsc -b, the touched server suites (dashboard-runs, lanes, lane-sources, me-prefs, repo-links, server-s3/server-mode: 744) and client suites (613) run by hand.
97 lines
4.2 KiB
TypeScript
97 lines
4.2 KiB
TypeScript
/**
|
|
* @file AgentInstructionsSettings.test.tsx
|
|
* @description My agent instructions: a failed load shows the error with a
|
|
* Retry (the editor stays locked, so a save cannot overwrite unloaded text)
|
|
* and Retry unlocks it; Choose file puts a local file's text into the draft
|
|
* without saving, and refuses one longer than the limit; "Applies to" names
|
|
* only the installed, enabled tools the server says receive them.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { AgentInstructionsSettings } from "../AgentInstructionsSettings";
|
|
import { api } from "../../../lib/api";
|
|
import { toast } from "../../../lib/toast";
|
|
|
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>();
|
|
return {
|
|
...actual,
|
|
api: { me: { instructions: vi.fn(), saveInstructions: vi.fn() } },
|
|
};
|
|
});
|
|
const aiTools = vi.hoisted(() => ({
|
|
tools: [] as { id: string; label: string; installed: boolean; enabled: boolean }[],
|
|
}));
|
|
vi.mock("../../../hooks/useAiTools", () => ({ useAiTools: () => aiTools }));
|
|
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
|
|
|
const instructions = vi.mocked(api.me.instructions);
|
|
const saveInstructions = vi.mocked(api.me.saveInstructions);
|
|
const editor = () => screen.getByRole("textbox") as HTMLTextAreaElement;
|
|
// jsdom's File has no text(); every browser AgentHub supports does.
|
|
const pick = (text: string, name = "rules.md") => {
|
|
const file = Object.assign(new File([text], name, { type: "text/markdown" }), {
|
|
text: () => Promise.resolve(text),
|
|
});
|
|
fireEvent.change(screen.getByTestId("agent-instructions-file"), { target: { files: [file] } });
|
|
};
|
|
|
|
describe("AgentInstructionsSettings", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
aiTools.tools = [];
|
|
});
|
|
|
|
it("names only the installed, enabled tools that receive the instructions", async () => {
|
|
aiTools.tools = [
|
|
{ id: "claude", label: "Claude Code", installed: true, enabled: true },
|
|
{ id: "codex", label: "Codex", installed: true, enabled: false },
|
|
{ id: "cursor", label: "Cursor CLI", installed: true, enabled: true },
|
|
];
|
|
instructions.mockResolvedValue({ instructions: "", max: 100, tools: ["claude", "codex"] });
|
|
render(<AgentInstructionsSettings />);
|
|
expect(await screen.findByText("Claude Code")).toBeInTheDocument();
|
|
expect(screen.getByText("Applies to")).toBeInTheDocument();
|
|
expect(screen.queryByText("Codex")).toBeNull();
|
|
expect(screen.queryByText("Cursor CLI")).toBeNull();
|
|
});
|
|
|
|
it("shows a failed load with Retry, and Retry unlocks the editor", async () => {
|
|
instructions.mockRejectedValueOnce(new Error("HTTP 404"));
|
|
render(<AgentInstructionsSettings />);
|
|
expect(await screen.findByText(/HTTP 404/)).toBeInTheDocument();
|
|
expect(editor()).toBeDisabled();
|
|
|
|
instructions.mockResolvedValueOnce({ instructions: "# saved", max: 100, tools: [] });
|
|
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
|
|
await waitFor(() => expect(editor()).not.toBeDisabled());
|
|
expect(editor().value).toBe("# saved");
|
|
});
|
|
|
|
it("Choose file fills the draft without saving; Save sends it", async () => {
|
|
instructions.mockResolvedValue({ instructions: "", max: 100, tools: [] });
|
|
saveInstructions.mockResolvedValue({ instructions: "# from file", max: 100, tools: [] });
|
|
render(<AgentInstructionsSettings />);
|
|
await waitFor(() => expect(editor()).not.toBeDisabled());
|
|
|
|
pick("# from file");
|
|
await waitFor(() => expect(editor().value).toBe("# from file"));
|
|
expect(saveInstructions).not.toHaveBeenCalled();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
|
await waitFor(() => expect(saveInstructions).toHaveBeenCalledWith("# from file"));
|
|
});
|
|
|
|
it("refuses a file longer than the limit and keeps the draft", async () => {
|
|
instructions.mockResolvedValue({ instructions: "keep", max: 5, tools: [] });
|
|
render(<AgentInstructionsSettings />);
|
|
await waitFor(() => expect(editor()).not.toBeDisabled());
|
|
|
|
pick("way too long");
|
|
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
|
expect(editor().value).toBe("keep");
|
|
});
|
|
});
|