Files
Termix/plugins/ai/tests/frontend/AgentPanel.test.tsx
T
ZacharyZcR bf817d8bd1 feat(ai): add remote coding agent sessions for SSH hosts (#1545)
* feat(ai): add host coding agent sessions with configured providers

* Wait for agent initialization and shutdown before resuming sessions

* Keep Pi transcripts outside its configuration migration directory

* Cover Pi session directory separation

* Update core permission catalog expectations for remote agents

* Install remote agent runtimes from verified official sources

* Wait for the identity panel credentials request in its test

* Add scoped SSH forwarding setup for remote agents

* Verify forwarding setup through a fresh SSH connection

* Bound the SSH forwarding verification timeout

* Return the forwarding verification callback result

* fix(ai): support agent send and interrupt keyboard shortcuts

* feat(ai): add agent queues attachments worktrees and session recovery

* fix(ai): handle deleted workspace paths and duplicate status events

* fix(ai): accept file references in root workspaces

* fix(ai): refresh paused queues after agent errors

* fix(ai): store agent sessions in plugin tables and lock down OpenCode

Agent sessions lived in a single ctx.kv value holding up to 2000 events,
which passed the 256 KB kv limit within minutes of a normal run. Every
save after that failed, so history, nativeId, queue and archive changes
were lost and every event wrote a kv audit row.

Sessions, events and queued prompts now have their own tables. A running
agent appends events in batches at most once a second with a lazy save;
session and queue changes save immediately. Prompts, drafts and stored
event text are capped in UTF-8 bytes to fit MySQL TEXT. kv:own is no
longer requested.

The OpenCode server now requires a per-session password, so other local
accounts on the host cannot drive the agent through its loopback port.

* test(upgrade): include agent session tables in the reviewed upgrade SQL
2026-10-03 16:01:47 -05:00

146 lines
4.8 KiB
TypeScript

import type { TabProps } from "@termix/plugin-sdk/frontend";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
const mocks = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
fetch: vi.fn(),
t: (key: string) => key,
}));
vi.mock("../../src/frontend/app-ref", () => ({
aiApp: () => ({ api: mocks, fetch: mocks.fetch }),
}));
vi.mock("../../src/frontend/ai-api", () => ({
getAiProviders: async () => [],
}));
vi.mock("react-i18next", async (original) => ({
...(await original<object>()),
useTranslation: () => ({ t: mocks.t }),
}));
vi.mock("../../src/frontend/AiMessage", () => ({ AiMessage: () => null }));
import { AgentPanel } from "../../src/frontend/agents/AgentPanel";
beforeEach(() => {
vi.resetAllMocks();
Element.prototype.scrollIntoView = vi.fn();
mocks.post.mockResolvedValue({ data: {} });
mocks.patch.mockResolvedValue({ data: {} });
mocks.fetch.mockImplementation(async () => new Response("", { status: 200 }));
});
afterEach(cleanup);
async function panel(status = "ready") {
const session = {
id: "test",
hostId: 2,
agent: "pi",
model: "test-model",
cwd: "/tmp",
status,
events: [],
};
mocks.get.mockImplementation(async (path: string) => ({
data: path === "/agents" ? { sessions: [session] } : session,
}));
render(
<AgentPanel
{...({
host: { id: "2", name: "test", ip: "127.0.0.1", port: 22 },
} as TabProps)}
/>,
);
fireEvent.click(await screen.findByText("Pi · test-model"));
const textarea = await screen.findByRole("textbox", {
name: "agents.prompt",
});
await waitFor(() => expect(mocks.fetch).toHaveBeenCalled());
fireEvent.change(textarea, { target: { value: "hello" } });
return textarea;
}
it("sends on Enter and clears the draft", async () => {
const textarea = await panel();
expect(fireEvent.keyDown(textarea, { key: "Enter" })).toBe(false);
await waitFor(() =>
expect(mocks.post).toHaveBeenCalledWith("/agents/test/input", {
type: "prompt",
text: "hello",
attachmentIds: [],
}),
);
await waitFor(() => expect((textarea as HTMLTextAreaElement).value).toBe(""));
expect(mocks.post).toHaveBeenCalledTimes(1);
});
it("preserves Shift+Enter, composition Enter, legacy IME keys and key repeats", async () => {
const textarea = await panel();
expect(fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true })).toBe(
true,
);
fireEvent.compositionStart(textarea);
expect(fireEvent.keyDown(textarea, { key: "Enter" })).toBe(true);
fireEvent.compositionEnd(textarea);
expect(fireEvent.keyDown(textarea, { key: "Enter", isComposing: true })).toBe(
true,
);
expect(fireEvent.keyDown(textarea, { key: "Enter", keyCode: 229 })).toBe(
true,
);
fireEvent.keyDown(textarea, { key: "Enter", repeat: true });
expect(mocks.post).not.toHaveBeenCalled();
});
it("does not submit whitespace via Enter or direct form submission", async () => {
const textarea = await panel();
fireEvent.change(textarea, { target: { value: " \n " } });
fireEvent.keyDown(textarea, { key: "Enter" });
fireEvent.submit(textarea.closest("form")!);
expect(mocks.post).not.toHaveBeenCalled();
});
it("interrupts only the focused panel and leaves the draft intact", async () => {
const textarea = await panel("running");
fireEvent.keyDown(document.body, { key: "Escape" });
fireEvent.compositionStart(textarea);
fireEvent.keyDown(textarea, { key: "Escape" });
fireEvent.compositionEnd(textarea);
fireEvent.keyDown(textarea, { key: "Escape", isComposing: true });
fireEvent.keyDown(textarea, { key: "Escape", keyCode: 229 });
fireEvent.keyDown(textarea, { key: "Escape", repeat: true });
expect(mocks.post).not.toHaveBeenCalled();
expect(fireEvent.keyDown(textarea, { key: "Escape" })).toBe(false);
await waitFor(() =>
expect(mocks.post).toHaveBeenCalledWith("/agents/test/input", {
type: "cancel",
}),
);
expect((textarea as HTMLTextAreaElement).value).toBe("hello");
});
it("ignores Escape for an idle session", async () => {
const textarea = await panel();
fireEvent.keyDown(textarea, { key: "Escape" });
expect(mocks.post).not.toHaveBeenCalled();
});
it("keeps the draft and permits retry when sending fails", async () => {
const textarea = await panel();
mocks.post.mockRejectedValueOnce(new Error("Connection lost"));
fireEvent.keyDown(textarea, { key: "Enter" });
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toBe("Connection lost"),
);
expect((textarea as HTMLTextAreaElement).value).toBe("hello");
fireEvent.keyDown(textarea, { key: "Enter" });
await waitFor(() => expect((textarea as HTMLTextAreaElement).value).toBe(""));
expect(mocks.post).toHaveBeenCalledTimes(2);
});