Files
Termix/plugins/ai/tests/backend/agents/store.test.ts
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

102 lines
3.1 KiB
TypeScript

import { afterEach, expect, it } from "vitest";
import {
createAgentStore,
EVENT_HISTORY,
} from "../../../src/backend/agents/store.js";
import { startServer, type TestServer } from "../helpers.js";
let server: TestServer;
afterEach(async () => {
await server?.close();
});
const session = (id: string) => ({
id,
userId: "user-1",
hostId: 1,
providerId: 1,
agent: "pi" as const,
model: "test",
cwd: "/tmp",
executable: "pi",
status: "stopped" as const,
events: [],
updatedAt: new Date().toISOString(),
});
it("keeps saving a session whose history is far larger than one value", async () => {
server = await startServer({ permissions: ["ai.agents"] });
await server.enableFor("user-1");
const store = await createAgentStore(server.mock.ctx);
const id = "33333333-3333-3333-3333-333333333333";
const s = session(id);
await store.save(s);
const chunk = "x".repeat(32000);
let seq = 0;
for (let batch = 0; batch < 30; batch++)
await store.append(
s,
Array.from({ length: 100 }, () => ({
seq: ++seq,
kind: "tool" as const,
text: chunk,
})),
);
await store.save({
...s,
nativeId: "native-1",
queue: [{ id: "q1", text: "next", attachmentIds: [] }],
});
const stored = await store.get("user-1", id);
expect(stored).toMatchObject({
nativeId: "native-1",
queue: [{ id: "q1", text: "next", attachmentIds: [] }],
});
const events = await store.events(id);
expect(events).toHaveLength(EVENT_HISTORY);
expect(events[0].seq).toBe(seq - EVENT_HISTORY + 1);
expect(events.at(-1)?.seq).toBe(seq);
expect(await store.events(id, seq - 2)).toHaveLength(2);
});
it("only returns a session to its owner and removes every row on delete", async () => {
server = await startServer({ permissions: ["ai.agents"] });
const store = await createAgentStore(server.mock.ctx);
const id = "44444444-4444-4444-4444-444444444444";
const s = session(id);
await store.save({
...s,
queue: [{ id: "q1", text: "a", attachmentIds: [] }],
});
await store.append(s, [{ seq: 1, kind: "user", text: "hello" }]);
expect(await store.get("user-2", id)).toBeNull();
expect(await store.list("user-2")).toEqual([]);
expect((await store.list("user-1")).map((x) => x.id)).toEqual([id]);
await store.delete(id);
expect(await store.get("user-1", id)).toBeNull();
expect(await store.events(id)).toEqual([]);
});
it("caps prompts and drafts by UTF-8 bytes so they fit MySQL TEXT", async () => {
server = await startServer({ permissions: ["ai.agents"] });
await server.enableFor("user-1");
const id = "55555555-5555-5555-5555-555555555555";
await (await createAgentStore(server.mock.ctx)).save(session(id));
const wide = "界".repeat(30000);
const draft = await server.request("PATCH", `/agents/${id}`, {
body: { draft: wide },
});
expect(draft.status).toBe(400);
const queued = await server.request("POST", `/agents/${id}/queue`, {
body: { operation: "add", text: wide },
});
expect(queued.status).toBe(400);
const ascii = await server.request("PATCH", `/agents/${id}`, {
body: { draft: "a".repeat(64000) },
});
expect(ascii.status).toBe(200);
});