mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
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
This commit is contained in:
1 parent
77bcbdb3d6
commit
bf817d8bd1
75 files changed
+7292
-6
No files matched your search
@@ -0,0 +1,145 @@
|
||||
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);
|
||||
});
|
||||
@@ -64,6 +64,8 @@ describe(`${manifest.id} activate`, () => {
|
||||
|
||||
it("adds the assistant tab once AI is on for the user", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
await waitFor(() => expect(rendered!.registered.tabs()).toEqual(["ai"]));
|
||||
await waitFor(() =>
|
||||
expect(rendered!.registered.tabs()).toEqual(["ai", "ai-agents"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import { useAgentStream } from "../../src/frontend/agents/useAgentStream";
|
||||
const api = vi.hoisted(() => ({ get: vi.fn(), fetch: vi.fn() }));
|
||||
vi.mock("../../src/frontend/app-ref", () => ({
|
||||
aiApp: () => ({ api, fetch: api.fetch }),
|
||||
}));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
it("restores a snapshot after an EOF and resumes after its last sequence", async () => {
|
||||
api.get
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
id: "x",
|
||||
status: "running",
|
||||
events: [{ seq: 1, kind: "user", text: "hi" }],
|
||||
},
|
||||
})
|
||||
.mockResolvedValue({
|
||||
data: {
|
||||
id: "x",
|
||||
status: "running",
|
||||
events: [
|
||||
{ seq: 1, kind: "user", text: "hi" },
|
||||
{ seq: 2, kind: "text", text: "recovered" },
|
||||
],
|
||||
},
|
||||
});
|
||||
api.fetch.mockResolvedValueOnce(new Response(""));
|
||||
let stream!: ReadableStreamDefaultController<Uint8Array>;
|
||||
api.fetch.mockImplementation(
|
||||
async () =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(c) {
|
||||
stream = c;
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const snapshot = vi.fn(),
|
||||
event = vi.fn();
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useAgentStream("x", 0, snapshot, event),
|
||||
);
|
||||
await waitFor(() => expect(result.current).toBe("reconnecting"));
|
||||
await waitFor(() => expect(api.fetch).toHaveBeenCalledTimes(2), {
|
||||
timeout: 2500,
|
||||
});
|
||||
expect(api.fetch.mock.calls[1][0]).toBe("agents/x/events?after=2");
|
||||
await act(async () => {
|
||||
stream.enqueue(
|
||||
new TextEncoder().encode(
|
||||
'data: {"seq":2,"kind":"text","text":"duplicate"}\n\ndata: {"seq":3,"kind":"text","text":"new"}\n\n',
|
||||
),
|
||||
);
|
||||
});
|
||||
expect(event).toHaveBeenCalledTimes(1);
|
||||
expect(event).toHaveBeenCalledWith({ seq: 3, kind: "text", text: "new" });
|
||||
expect(snapshot.mock.lastCall?.[0].events[1].text).toBe("recovered");
|
||||
unmount();
|
||||
stream.close();
|
||||
});
|
||||
it("does not reconnect an inaccessible session", async () => {
|
||||
api.get.mockRejectedValue({ response: { status: 403 } });
|
||||
const { result } = renderHook(() => useAgentStream("x", 0, vi.fn(), vi.fn()));
|
||||
await waitFor(() => expect(result.current).toBe("unavailable"));
|
||||
expect(api.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
Reference in new issue
Block a user