mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25: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,140 @@
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { createAgentStore } from "../../../src/backend/agents/store.js";
|
||||
import type { AgentEvent } from "../../../src/backend/agents/types.js";
|
||||
import { startServer, type TestServer } from "../helpers.js";
|
||||
let server: TestServer;
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
async function seed(id: string, events: Omit<AgentEvent, "seq">[] = []) {
|
||||
const store = await createAgentStore(server.mock.ctx);
|
||||
const session = {
|
||||
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(),
|
||||
};
|
||||
await store.save(session);
|
||||
await store.append(
|
||||
session,
|
||||
events.map((e, i) => ({ ...e, seq: i + 1 })),
|
||||
);
|
||||
}
|
||||
it("requires the separate agent permission even when AI is enabled", async () => {
|
||||
server = await startServer();
|
||||
await server.enableFor("user-1");
|
||||
expect((await server.request("GET", "/agents")).status).toBe(403);
|
||||
});
|
||||
it("respects the global AI gate for agent routes", async () => {
|
||||
server = await startServer({ permissions: ["ai.agents"] });
|
||||
expect((await server.request("GET", "/agents")).status).toBe(403);
|
||||
await server.enableFor("user-1");
|
||||
expect((await server.request("GET", "/agents")).status).toBe(200);
|
||||
});
|
||||
it("does not expose another user's persisted session or allow a malformed launch", async () => {
|
||||
server = await startServer({ permissions: ["ai.agents"] });
|
||||
await server.enableFor("user-1");
|
||||
await server.enableFor("user-2");
|
||||
const id = "11111111-1111-1111-1111-111111111111";
|
||||
await seed(id, [{ kind: "text", text: "private transcript" }]);
|
||||
const other = await server.request("GET", `/agents/${id}`, {
|
||||
user: "user-2",
|
||||
});
|
||||
expect(other.status).toBe(400);
|
||||
expect(JSON.stringify(other.body)).not.toContain("private transcript");
|
||||
const invalid = await server.request("POST", "/agents", {
|
||||
body: {
|
||||
agent: "pi",
|
||||
hostId: 1,
|
||||
providerId: 1,
|
||||
model: "test",
|
||||
cwd: "/tmp",
|
||||
executable: "pi; uname",
|
||||
},
|
||||
});
|
||||
expect(invalid.status).toBe(400);
|
||||
});
|
||||
it("gates installation before SSH and rejects caller-supplied commands", async () => {
|
||||
server = await startServer();
|
||||
await server.enableFor("user-1");
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/agents/install", {
|
||||
body: { hostId: 1, agent: "pi" },
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
});
|
||||
it("rejects arbitrary installer names", async () => {
|
||||
server = await startServer({ permissions: ["ai.agents"] });
|
||||
await server.enableFor("user-1");
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/agents/install", {
|
||||
body: { hostId: 1, agent: "pi; id" },
|
||||
})
|
||||
).status,
|
||||
).toBe(400);
|
||||
});
|
||||
it("requires agent permission for forwarding configuration", async () => {
|
||||
server = await startServer();
|
||||
await server.enableFor("user-1");
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/agents/forwarding", {
|
||||
body: { hostId: 1 },
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
});
|
||||
|
||||
it("persists session metadata and queued drafts without exposing them to another user", async () => {
|
||||
server = await startServer({ permissions: ["ai.agents"] });
|
||||
await server.enableFor("user-1");
|
||||
await server.enableFor("user-2");
|
||||
const id = "22222222-2222-2222-2222-222222222222";
|
||||
await seed(id);
|
||||
expect(
|
||||
(
|
||||
await server.request("PATCH", `/agents/${id}`, {
|
||||
body: { title: "Private", draft: "Keep this draft" },
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/agents/${id}/queue`, {
|
||||
body: { operation: "add", text: "queued" },
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
const s = (await server.request("GET", `/agents/${id}`)).body;
|
||||
expect(s).toMatchObject({
|
||||
title: "Private",
|
||||
draft: "Keep this draft",
|
||||
queue: [{ text: "queued" }],
|
||||
});
|
||||
for (const [method, path, body] of [
|
||||
["PATCH", `/agents/${id}`, { title: "hijack" }],
|
||||
["POST", `/agents/${id}/queue`, { operation: "add", text: "injected" }],
|
||||
["POST", `/agents/${id}/workspace`, { operation: "status" }],
|
||||
] as const)
|
||||
expect(
|
||||
(await server.request(method, path, { user: "user-2", body })).status,
|
||||
).toBe(400);
|
||||
await server.request("PATCH", `/agents/${id}`, { body: { archived: true } });
|
||||
expect((await server.request("POST", `/agents/${id}/resume`)).status).toBe(
|
||||
400,
|
||||
);
|
||||
expect((await server.request("GET", `/agents/${id}`)).body).toMatchObject({
|
||||
archived: true,
|
||||
queuePaused: true,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import {
|
||||
compatible,
|
||||
shellQuote,
|
||||
validStart,
|
||||
} from "../../../src/backend/agents/types.js";
|
||||
import { providerPath } from "../../../src/backend/agents/routes.js";
|
||||
import { REMOTE_RUNNER } from "../../../src/backend/agents/remote-runner.js";
|
||||
|
||||
describe("remote agent boundaries", () => {
|
||||
it("rejects shell syntax and invalid targets", () => {
|
||||
const start = {
|
||||
agent: "pi",
|
||||
hostId: 1,
|
||||
providerId: 1,
|
||||
model: "glm",
|
||||
cwd: "/tmp",
|
||||
};
|
||||
expect(validStart(start)).toBe(true);
|
||||
for (const patch of [
|
||||
{ cwd: "relative" },
|
||||
{ hostId: -1 },
|
||||
{ agent: "shell" },
|
||||
{ executable: "pi; id" },
|
||||
{ cwd: "/tmp\0" },
|
||||
])
|
||||
expect(validStart({ ...start, ...patch })).toBe(false);
|
||||
expect(shellQuote("it's $(secret)")).toBe("'it'\\''s $(secret)'");
|
||||
});
|
||||
it("keeps model protocols explicit", () => {
|
||||
expect(compatible("claude", "openai_compatible")).toBe(false);
|
||||
expect(compatible("pi", "openai_compatible")).toBe(true);
|
||||
expect(compatible("codex", "gemini")).toBe(false);
|
||||
});
|
||||
it("limits the reverse proxy to inference endpoints", () => {
|
||||
expect(providerPath("/v1/chat/completions")).toBe("/chat/completions");
|
||||
expect(providerPath("/v1/responses")).toBe("/responses");
|
||||
expect(providerPath("/v1/files")).toBeNull();
|
||||
expect(providerPath("/v1/../admin")).toBeNull();
|
||||
expect(providerPath("//evil.test/secrets")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const fake = String.raw`#!/usr/bin/env node
|
||||
const fs=require('fs'),http=require('http'),rl=require('readline');
|
||||
const emit=m=>console.log(JSON.stringify(m));
|
||||
const args=process.argv.slice(2);
|
||||
if(args.includes('--session') && require('path').dirname(args[args.indexOf('--session')+1])===process.env.PI_CODING_AGENT_DIR) {console.error('Pi migrates session files from its config root');process.exit(1);}
|
||||
if(args[0]==='serve') {
|
||||
let stream;const port=Number(args[args.indexOf('--port')+1]);
|
||||
http.createServer(async(q,r)=>{
|
||||
if(!process.env.OPENCODE_SERVER_PASSWORD || q.headers.authorization!=='Basic '+Buffer.from('opencode:'+process.env.OPENCODE_SERVER_PASSWORD).toString('base64')){r.statusCode=401;r.end();return;}
|
||||
if(q.url==='/event'){r.writeHead(200,{'Content-Type':'text/event-stream'});r.write(': hello\n\n');stream=r;return;}
|
||||
r.setHeader('Content-Type','application/json');
|
||||
if(q.url==='/session')return r.end(JSON.stringify({id:'oc-session'}));
|
||||
if(q.url.includes('prompt_async')){let body='';for await(const d of q)body+=d;const parsed=JSON.parse(body);if(!parsed.parts.some(p=>p.type==='file' && p.mime==='image/png' && p.url==='data:image/png;base64,aW1hZ2U=')){r.statusCode=400;r.end('{}');return;}r.statusCode=204;r.end();setTimeout(()=>{stream.write('data: '+JSON.stringify({type:'message.part.updated',properties:{part:{id:'text-1',type:'text',sessionID:'oc-session'}}})+'\n\n');stream.write('data: '+JSON.stringify({type:'message.part.delta',properties:{sessionID:'oc-session',partID:'text-1',field:'text',delta:'verified'}})+'\n\n');stream.write('data: '+JSON.stringify({type:'session.idle',properties:{sessionID:'oc-session'}})+'\n\n');},30);return;}
|
||||
r.end('{}');
|
||||
}).listen(port,'127.0.0.1');
|
||||
} else rl.createInterface({input:process.stdin}).on('line',line=>{
|
||||
const m=JSON.parse(line);
|
||||
if(args[0]==='app-server') {
|
||||
if(m.method==='initialize')emit({id:m.id,result:{}});
|
||||
if(m.method==='thread/start'||m.method==='thread/resume')emit({id:m.id,result:{thread:{id:'codex-thread'}}});
|
||||
if(m.method==='turn/start'){if(!m.params.input.some(i=>i.type==='image' && i.url==='data:image/png;base64,aW1hZ2U='))throw Error('Missing Codex image');emit({id:m.id,result:{}});emit({method:'item/agentMessage/delta',params:{delta:'verified'}});emit({method:'turn/completed',params:{turn:{status:'completed'}}});}
|
||||
} else if(args.includes('--input-format')) {
|
||||
if(m.type==='user'){if(!m.message.content.some(i=>i.type==='image' && i.source.media_type==='image/png' && i.source.data==='aW1hZ2U='))throw Error('Missing Claude image');}if(m.type==='user')emit({type:'control_request',request_id:'approval',request:{subtype:'can_use_tool',tool_name:'Read',input:{path:'/tmp'}}});
|
||||
if(m.type==='control_response'){emit({type:'stream_event',event:{type:'content_block_delta',delta:{type:'text_delta',text:'verified'}}});emit({type:'result',is_error:false});}
|
||||
} else if(m.type==='get_state') {emit({type:'response',id:m.id,success:true,data:{sessionId:'pi-session'}});
|
||||
} else if(m.type==='prompt') {
|
||||
if(m.images?.[0]?.data!=='aW1hZ2U=' || m.images?.[0]?.mimeType!=='image/png')throw Error('Missing Pi image');
|
||||
emit({type:'message_update',assistantMessageEvent:{type:'text_delta',delta:'verified'}});emit({type:'agent_end'});
|
||||
}
|
||||
});
|
||||
`;
|
||||
for (const agent of ["pi", "claude", "codex", "opencode"] as const) {
|
||||
it(`${agent}: native protocol streams a turn and returns to ready`, async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "termix-agent-test-"));
|
||||
const executable = join(dir, "fake-agent");
|
||||
const image = join(dir, "image.png");
|
||||
await writeFile(image, "image");
|
||||
await writeFile(executable, fake, { mode: 0o700 });
|
||||
const child = spawn(process.execPath, ["-e", REMOTE_RUNNER], {
|
||||
env: { ...process.env, HOME: dir },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
const events: Record<string, unknown>[] = [];
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (d) => (stderr += d));
|
||||
const send = (m: unknown) => child.stdin.write(JSON.stringify(m) + "\n");
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(Error("timeout " + stderr + JSON.stringify(events))),
|
||||
12000,
|
||||
);
|
||||
let prompted = false;
|
||||
createInterface({ input: child.stdout }).on("line", (line) => {
|
||||
const m = JSON.parse(line);
|
||||
events.push(m);
|
||||
if (m.kind === "error") {
|
||||
clearTimeout(timer);
|
||||
reject(Error(m.text));
|
||||
}
|
||||
if (m.kind === "permission")
|
||||
send({ type: "answer", requestId: m.requestId, allow: true });
|
||||
if (m.kind === "status" && m.text === "ready") {
|
||||
if (!prompted) {
|
||||
prompted = true;
|
||||
send({
|
||||
type: "prompt",
|
||||
text: "hello",
|
||||
attachments: [{ path: image, mime: "image/png" }],
|
||||
});
|
||||
} else {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
});
|
||||
send({
|
||||
type: "start",
|
||||
config: {
|
||||
agent,
|
||||
id: "test-session",
|
||||
cwd: dir,
|
||||
executable,
|
||||
model: "test-model",
|
||||
proxyUrl: "http://127.0.0.1:1",
|
||||
token: "temporary-token",
|
||||
providerType:
|
||||
agent === "claude" ? "anthropic" : "openai_compatible",
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(
|
||||
events.some((e) => e.kind === "text" && e.text === "verified"),
|
||||
).toBe(true);
|
||||
if (agent === "claude")
|
||||
expect(events.some((e) => e.kind === "permission")).toBe(true);
|
||||
} finally {
|
||||
send({ type: "stop" });
|
||||
await new Promise((resolve) => child.once("exit", resolve));
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 15000);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { mkdtemp, writeFile, readFile, mkdir, rm } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
FORWARDING_ADMIN_SCRIPT,
|
||||
forwardingScript,
|
||||
} from "../../../src/backend/agents/forwarding.js";
|
||||
|
||||
async function fixture(mode = "no") {
|
||||
const dir = await mkdtemp(join(tmpdir(), "termix-forwarding-"));
|
||||
const bin = join(dir, "bin");
|
||||
await mkdir(bin);
|
||||
const original = `Port 2222\nAllowTcpForwarding ${mode}\nMatch Address 10.1.1.3\n DenyUsers root\n`;
|
||||
await writeFile(join(dir, "sshd_config"), original);
|
||||
await writeFile(
|
||||
join(bin, "sshd"),
|
||||
`#!${process.execPath}
|
||||
const fs=require('fs'),a=process.argv.slice(2),dir=${JSON.stringify(dir)};
|
||||
const file=a.includes('-f')?a[a.indexOf('-f')+1]:dir+'/sshd_config';
|
||||
if(a.includes('-t')){if(a.includes('-f')&&fs.existsSync(dir+'/invalid-candidate'))process.exit(1);process.exit(0);}
|
||||
const text=fs.readFileSync(file,'utf8'),rule=text.match(/Match User root Address 10.1.1.35\\n AllowTcpForwarding (\\w+)/);
|
||||
console.log('allowtcpforwarding '+(rule?rule[1]:${JSON.stringify(mode)}));
|
||||
console.log('permitlisten '+(rule?'127.0.0.1:*':'any'));
|
||||
console.log('gatewayports no\\ndisableforwarding no');
|
||||
`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
await writeFile(
|
||||
join(bin, "systemctl"),
|
||||
`#!/bin/sh
|
||||
if [ "$1" = reload ] && [ -f '${dir}/fail-reload' ]; then rm '${dir}/fail-reload'; exit 1; fi
|
||||
exit 0
|
||||
`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
const script = FORWARDING_ADMIN_SCRIPT.replaceAll(
|
||||
"/usr/sbin/sshd",
|
||||
join(bin, "sshd"),
|
||||
).replaceAll("/etc/ssh", dir);
|
||||
const run = () =>
|
||||
execFileSync("sh", ["-c", script, "termix", "root", "10.1.1.35"], {
|
||||
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
|
||||
stdio: "pipe",
|
||||
}).toString();
|
||||
return {
|
||||
dir,
|
||||
original,
|
||||
run,
|
||||
config: () => readFile(join(dir, "sshd_config"), "utf8"),
|
||||
close: () => rm(dir, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
it("adds a scoped rule before existing Match blocks and is idempotent", async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
expect(f.run()).toContain("Enabled loopback remote forwarding");
|
||||
const config = await f.config();
|
||||
expect(config).toContain(
|
||||
"AllowTcpForwarding no\n# BEGIN Termix Agent root 10.1.1.35\nMatch User root Address 10.1.1.35\n AllowTcpForwarding remote\n PermitListen 127.0.0.1:*\n GatewayPorts no",
|
||||
);
|
||||
expect(config).toContain("Match Address 10.1.1.3\n DenyUsers root");
|
||||
expect(f.run()).toContain("already allowed");
|
||||
expect(await f.config()).toBe(config);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
it("preserves existing local forwarding", async () => {
|
||||
const f = await fixture("local");
|
||||
try {
|
||||
f.run();
|
||||
expect(await f.config()).toContain(" AllowTcpForwarding yes");
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
for (const failure of ["invalid-candidate", "fail-reload"]) {
|
||||
it(`retains the original SSH configuration after ${failure}`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await writeFile(join(f.dir, failure), "1");
|
||||
expect(f.run).toThrow();
|
||||
expect(await f.config()).toBe(f.original);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
it("rejects a malformed connection source before privilege escalation", () => {
|
||||
expect(() =>
|
||||
execFileSync("sh", ["-c", forwardingScript()], {
|
||||
env: { ...process.env, SSH_CONNECTION: "1.2.3.4;bad 123 1.2.3.5 22" },
|
||||
stdio: "pipe",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { mkdtemp, mkdir, writeFile, rm, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { installScript } from "../../../src/backend/agents/install.js";
|
||||
|
||||
it("refuses corrupted Node.js downloads before extracting or installing", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "termix-install-"));
|
||||
try {
|
||||
const bin = join(dir, "bin");
|
||||
await mkdir(bin);
|
||||
await writeFile(
|
||||
join(bin, "curl"),
|
||||
`#!/bin/sh
|
||||
for arg do previous="$last"; last="$arg"; done
|
||||
case "$last" in
|
||||
*SHASUMS256.txt) printf '%064d node-v24.1.0-linux-x64.tar.gz\\n' 0 > "$last";;
|
||||
*) echo corrupted > "$last";;
|
||||
esac
|
||||
`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
await writeFile(
|
||||
join(bin, "uname"),
|
||||
'#!/bin/sh\nif [ "$1" = -s ]; then echo Linux; else echo x86_64; fi\n',
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
let output = "";
|
||||
try {
|
||||
execFileSync("sh", ["-c", installScript("pi")], {
|
||||
env: { ...process.env, HOME: dir, PATH: `${bin}:${process.env.PATH}` },
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
output = String((error as { stdout: Buffer }).stdout);
|
||||
}
|
||||
expect(output).toContain("Node.js SHA-256 verification failed");
|
||||
await expect(
|
||||
readFile(join(dir, ".local/share/termix-agent-runtime/node/bin/node")),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a mirror dependency before npm ci", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "termix-registry-"));
|
||||
try {
|
||||
const script = installScript("pi")
|
||||
.split("node - <<'VERIFY'\n")[1]
|
||||
.split("\nVERIFY")[0];
|
||||
await writeFile(
|
||||
join(dir, "package-lock.json"),
|
||||
JSON.stringify({
|
||||
packages: {
|
||||
"node_modules/injected": {
|
||||
version: "1.0.0",
|
||||
resolved: "https://mirror.invalid/pkg.tgz",
|
||||
integrity: "sha512-fake",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(() =>
|
||||
execFileSync(process.execPath, ["-e", script], {
|
||||
cwd: dir,
|
||||
stdio: "pipe",
|
||||
}),
|
||||
).toThrow();
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { Duplex, PassThrough } from "node:stream";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { startServer, type TestServer } from "../helpers.js";
|
||||
let server: TestServer;
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
it("dispatches FIFO once per completed turn and pauses pending work when cancelled", async () => {
|
||||
server = await startServer({
|
||||
permissions: ["ai.agents", "ai.manage_providers"],
|
||||
});
|
||||
await server.enableFor("user-1");
|
||||
const prompts: { text: string }[] = [];
|
||||
class Channel extends Duplex {
|
||||
stderr = new PassThrough();
|
||||
_read() {}
|
||||
_write(chunk: Buffer, _encoding: string, done: () => void) {
|
||||
const m = JSON.parse(chunk.toString());
|
||||
if (m.type === "start") this.complete();
|
||||
if (m.type === "prompt") prompts.push(m);
|
||||
if (m.type === "stop") {
|
||||
this.push(null);
|
||||
queueMicrotask(() => this.emit("close"));
|
||||
}
|
||||
done();
|
||||
}
|
||||
complete() {
|
||||
this.push(JSON.stringify({ kind: "status", text: "ready" }) + "\n");
|
||||
}
|
||||
}
|
||||
const channel = new Channel();
|
||||
const client = Object.assign(new EventEmitter(), {
|
||||
forwardIn: (
|
||||
_host: string,
|
||||
_port: number,
|
||||
callback: (error: null, port: number) => void,
|
||||
) => callback(null, 30000),
|
||||
exec: (_cmd: string, callback: (error: null, ch: Channel) => void) =>
|
||||
callback(null, channel),
|
||||
});
|
||||
vi.spyOn(server.mock.ctx.ssh, "connect").mockResolvedValue({
|
||||
client,
|
||||
dispose: () => channel.destroy(),
|
||||
} as never);
|
||||
const p = await server.request("POST", "/providers", {
|
||||
body: {
|
||||
providerType: "openai",
|
||||
label: "Test",
|
||||
apiKey: "unused",
|
||||
defaultModel: "test",
|
||||
},
|
||||
});
|
||||
const started = await server.request("POST", "/agents", {
|
||||
body: {
|
||||
agent: "pi",
|
||||
hostId: 1,
|
||||
providerId: p.body.provider.id,
|
||||
model: "test",
|
||||
cwd: "/tmp",
|
||||
},
|
||||
});
|
||||
expect(started.status).toBe(201);
|
||||
const id = started.body.id;
|
||||
try {
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/agents/${id}/input`, {
|
||||
body: { type: "prompt", text: "first" },
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
for (const text of ["second", "third"])
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/agents/${id}/queue`, {
|
||||
body: { operation: "add", text },
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(prompts.map((p) => p.text)).toEqual(["first"]);
|
||||
channel.complete();
|
||||
await vi.waitFor(() =>
|
||||
expect(prompts.map((p) => p.text)).toEqual(["first", "second"]),
|
||||
);
|
||||
await server.request("POST", `/agents/${id}/input`, {
|
||||
body: { type: "cancel" },
|
||||
});
|
||||
channel.complete();
|
||||
await vi.waitFor(async () =>
|
||||
expect((await server.request("GET", `/agents/${id}`)).body.status).toBe(
|
||||
"ready",
|
||||
),
|
||||
);
|
||||
expect(prompts).toHaveLength(2);
|
||||
const paused = (await server.request("GET", `/agents/${id}`)).body;
|
||||
expect(paused.queuePaused).toBe(true);
|
||||
expect(paused.queue.map((p: { text: string }) => p.text)).toEqual([
|
||||
"third",
|
||||
]);
|
||||
await server.request("POST", `/agents/${id}/queue`, {
|
||||
body: { operation: "continue" },
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(prompts.map((p) => p.text)).toEqual(["first", "second", "third"]),
|
||||
);
|
||||
} finally {
|
||||
await server.request("POST", `/agents/${id}/stop`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
updateQueue,
|
||||
updateSession,
|
||||
promptInput,
|
||||
} from "../../../src/backend/agents/session-state.js";
|
||||
import type { AgentSession } from "../../../src/backend/agents/types.js";
|
||||
const session = () =>
|
||||
({ queue: [], attachments: [{ id: "image" }] }) as unknown as AgentSession;
|
||||
it("queues, edits and removes messages without replacing their IDs", () => {
|
||||
const s = session();
|
||||
updateQueue(s, { operation: "add", text: "first" });
|
||||
updateQueue(s, { operation: "add", text: "second" });
|
||||
const id = s.queue![0].id;
|
||||
updateQueue(s, {
|
||||
operation: "update",
|
||||
id,
|
||||
text: "edited",
|
||||
attachmentIds: ["image"],
|
||||
});
|
||||
expect(s.queue![0]).toEqual({ id, text: "edited", attachmentIds: ["image"] });
|
||||
updateQueue(s, { operation: "pause" });
|
||||
expect(s.queuePaused).toBe(true);
|
||||
updateQueue(s, { operation: "continue" });
|
||||
expect(s.queuePaused).toBe(false);
|
||||
updateQueue(s, { operation: "remove", id });
|
||||
expect(s.queue!.map((p) => p.text)).toEqual(["second"]);
|
||||
expect(() =>
|
||||
updateQueue(s, { operation: "update", id, text: "late edit" }),
|
||||
).toThrow();
|
||||
});
|
||||
it("rejects foreign attachments and caps queues", () => {
|
||||
const s = session();
|
||||
expect(() =>
|
||||
promptInput(s, { text: "x", attachmentIds: ["other-session"] }),
|
||||
).toThrow();
|
||||
for (let i = 0; i < 20; i++) updateQueue(s, { operation: "add", text: "x" });
|
||||
expect(() =>
|
||||
updateQueue(s, { operation: "add", text: "overflow" }),
|
||||
).toThrow();
|
||||
});
|
||||
it("validates metadata before applying changes", () => {
|
||||
const s = session();
|
||||
updateSession(s, { title: "name", draft: "draft", archived: true });
|
||||
expect(s).toMatchObject({ title: "name", draft: "draft", archived: true });
|
||||
expect(() => updateSession(s, { title: "replacement", draft: 42 })).toThrow();
|
||||
expect(s.title).toBe("name");
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, afterEach, expect, it } from "vitest";
|
||||
import {
|
||||
mkdtempSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
mkdirSync,
|
||||
symlinkSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync, execFileSync } from "node:child_process";
|
||||
import { REMOTE_WORKSPACE } from "../../../src/backend/agents/workspace.js";
|
||||
let home: string, cwd: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "termix-workspace-"));
|
||||
cwd = join(home, "repo");
|
||||
mkdirSync(cwd);
|
||||
});
|
||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||
function run(body: unknown) {
|
||||
const r = spawnSync(process.execPath, ["-e", REMOTE_WORKSPACE], {
|
||||
env: { ...process.env, HOME: home },
|
||||
input: JSON.stringify({ cwd, id: "test", body }),
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (r.status) throw Error(r.stderr);
|
||||
return JSON.parse(r.stdout);
|
||||
}
|
||||
function git(...args: string[]) {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
it("uploads an image without trusting the caller's MIME type and preserves exact bytes", () => {
|
||||
const data = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 1, 2]);
|
||||
const a = run({
|
||||
operation: "upload",
|
||||
name: "image.png",
|
||||
data: data.toString("base64"),
|
||||
});
|
||||
expect(a.mime).toBe("image/png");
|
||||
expect(readFileSync(a.path)).toEqual(data);
|
||||
expect(
|
||||
a.path.startsWith(
|
||||
join(home, ".local/state/termix-agents/test/attachments"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
it("rejects oversized files, directories and references outside the workspace", () => {
|
||||
writeFileSync(join(home, "outside"), "secret");
|
||||
symlinkSync(join(home, "outside"), join(cwd, "escape"));
|
||||
writeFileSync(join(cwd, "large"), Buffer.alloc(1024 * 1024 + 1));
|
||||
for (const path of ["../outside", "escape", "large", "."])
|
||||
expect(() => run({ operation: "reference", path })).toThrow();
|
||||
});
|
||||
it("shows staged, unstaged and untracked changes and creates an isolated branch", () => {
|
||||
git("init");
|
||||
git("config", "user.name", "Test");
|
||||
git("config", "user.email", "test@example.com");
|
||||
mkdirSync(join(cwd, "nested"));
|
||||
writeFileSync(join(cwd, "nested", "deleted.txt"), "nested base\n");
|
||||
writeFileSync(join(cwd, "file.txt"), "base\n");
|
||||
git("add", ".");
|
||||
git("commit", "-m", "initial");
|
||||
writeFileSync(join(cwd, "file.txt"), "staged\n");
|
||||
git("add", ".");
|
||||
writeFileSync(join(cwd, "file.txt"), "working\n");
|
||||
writeFileSync(join(cwd, "new.txt"), "untracked");
|
||||
expect(run({ operation: "status" }).files).toContainEqual({
|
||||
status: "??",
|
||||
path: "new.txt",
|
||||
});
|
||||
const diff = run({ operation: "diff", path: "file.txt" }).diff;
|
||||
expect(diff).toContain("+working");
|
||||
expect(diff).toContain("+staged");
|
||||
expect(run({ operation: "diff", path: "new.txt" }).diff).toContain(
|
||||
"+untracked",
|
||||
);
|
||||
const wt = run({ operation: "worktree", branch: "termix/test" });
|
||||
expect(readFileSync(join(wt.path, "file.txt"), "utf8")).toBe("base\n");
|
||||
expect(readFileSync(join(cwd, "file.txt"), "utf8")).toBe("working\n");
|
||||
expect(() => run({ operation: "worktree", branch: "termix/test" })).toThrow();
|
||||
rmSync(join(cwd, "nested"), { recursive: true });
|
||||
expect(run({ operation: "diff", path: "nested/deleted.txt" }).diff).toContain(
|
||||
"-nested base",
|
||||
);
|
||||
rmSync(join(cwd, "file.txt"));
|
||||
expect(run({ operation: "diff", path: "file.txt" }).diff).toContain(
|
||||
"-staged",
|
||||
);
|
||||
});
|
||||
@@ -46,6 +46,9 @@ describe("ai adoption migration", () => {
|
||||
});
|
||||
|
||||
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",
|
||||
@@ -83,6 +86,9 @@ describe("ai adoption migration", () => {
|
||||
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",
|
||||
|
||||
@@ -17,6 +17,7 @@ const EXPECTED = [
|
||||
"ai.apply_proposals",
|
||||
"ai.services.use",
|
||||
"ai.secrets.share",
|
||||
"ai.agents",
|
||||
];
|
||||
|
||||
describe("ai permissions", () => {
|
||||
@@ -54,7 +55,9 @@ describe("ai permissions", () => {
|
||||
it("keeps the fresh-install defaults core used to seed", () => {
|
||||
for (const permission of manifest.manifest?.contributes?.permissions ??
|
||||
[]) {
|
||||
expect(permission.defaultRoles).toEqual(["user"]);
|
||||
expect(permission.defaultRoles).toEqual([
|
||||
permission.name === "agents" ? "admin" : "user",
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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