Files
Termix/plugins/ai/tests/backend/migrations.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

125 lines
3.9 KiB
TypeScript

import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { LEGACY_DDL, pluginDir } from "./helpers";
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tables = (sqlite: TestDb["sqlite"]) =>
(
sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%ai_%' ORDER BY name",
)
.all() as Array<{ name: string }>
).map((row) => row.name);
const indexes = (sqlite: TestDb["sqlite"], table: string) =>
(
sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name LIKE 'idx_%' ORDER BY name",
)
.all(table) as Array<{ name: string }>
).map((row) => row.name);
describe("ai adoption migration", () => {
it("renames the legacy tables and keeps every row, index and cascade", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec(LEGACY_DDL);
sqlite
.prepare("INSERT INTO users (id, username) VALUES ('u1', 'u1')")
.run();
sqlite.exec(`
INSERT INTO ai_providers (id, user_id, provider_type, label, api_key, api_key_prefix)
VALUES (3, 'u1', 'ollama', 'Local', 'enc-key', 'abc123');
INSERT INTO ai_conversations (id, user_id, title) VALUES (5, 'u1', 'Disk');
INSERT INTO ai_messages (conversation_id, role, content) VALUES (5, 'user', 'why full');
INSERT INTO ai_proposals (conversation_id, user_id, kind) VALUES (5, 'u1', 'propose_run_command');
`);
},
});
expect(tables(db.sqlite)).toEqual([
"p_ai_agent_events",
"p_ai_agent_queue",
"p_ai_agent_sessions",
"p_ai_conversations",
"p_ai_messages",
"p_ai_proposals",
"p_ai_providers",
]);
const provider = db.sqlite
.prepare("SELECT * FROM p_ai_providers WHERE id = 3")
.get() as Record<string, unknown>;
// The key stays until core's boot migration moves it into ctx.secrets.
expect(provider).toMatchObject({ label: "Local", api_key: "enc-key" });
expect(indexes(db.sqlite, "p_ai_providers")).toEqual([
"idx_ai_providers_user_label",
]);
expect(indexes(db.sqlite, "p_ai_proposals")).toEqual([
"idx_ai_proposals_conversation",
"idx_ai_proposals_user",
]);
// The rename carried the foreign keys: a conversation still takes its
// messages and proposals with it.
db.sqlite.prepare("DELETE FROM p_ai_conversations WHERE id = 5").run();
const count = (table: string) =>
(
db!.sqlite.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as {
n: number;
}
).n;
expect(count("p_ai_messages")).toBe(0);
expect(count("p_ai_proposals")).toBe(0);
});
it("creates fresh tables when there is nothing to adopt", async () => {
db = await createTestDb(pluginDir);
expect(tables(db.sqlite)).toEqual([
"p_ai_agent_events",
"p_ai_agent_queue",
"p_ai_agent_sessions",
"p_ai_conversations",
"p_ai_messages",
"p_ai_proposals",
"p_ai_providers",
]);
const columns = (
db.sqlite.prepare("PRAGMA table_info(p_ai_providers)").all() as Array<{
name: string;
}>
).map((column) => column.name);
expect(columns).not.toContain("api_key");
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES ('u1', 'u1')")
.run();
db.sqlite
.prepare("INSERT INTO p_ai_conversations (id, user_id) VALUES (1, 'u1')")
.run();
db.sqlite
.prepare(
"INSERT INTO p_ai_messages (conversation_id, role) VALUES (1, 'user')",
)
.run();
db.sqlite.prepare("DELETE FROM p_ai_conversations WHERE id = 1").run();
expect(
(
db.sqlite.prepare("SELECT COUNT(*) AS n FROM p_ai_messages").get() as {
n: number;
}
).n,
).toBe(0);
});
});