chore: v2.8.13 — Artifacts reinstall/edit/installs, agent steps in chat, pick a local file to attach, Windows paths as links
- Chat: an Agent step shows its task, status and tool calls and opens its conversation in place; Windows paths (E:\…, C:/…) in replies open as files - Prompt: attach a file on this computer by path (native picker), clearer too-large messages - Artifacts: edit title/description and a skill's files (new snapshot), Reinstall, install history, Hub-only rows, skills installed into ~/.claude/skills and ~/.agents/skills - Files: batch upload of small files in one extract - AI Tools: npm install falls back to --prefix ~/.local when the global prefix is not writable
This commit is contained in:
@@ -181,6 +181,58 @@ describe("stepsFor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a system-wide npm prefix", () => {
|
||||
const ro = path.join(root, "ro-prefix");
|
||||
const user = path.join(root, "user-local");
|
||||
const asRoot = process.getuid?.() === 0;
|
||||
|
||||
before(() => {
|
||||
for (const d of [path.join(ro, "lib", "node_modules"), path.join(ro, "bin")]) {
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
fs.chmodSync(d, 0o555);
|
||||
}
|
||||
versions.__setUserPrefix(user);
|
||||
});
|
||||
after(() => {
|
||||
versions.__setUserPrefix(undefined);
|
||||
for (const d of [path.join(ro, "lib", "node_modules"), path.join(ro, "bin")]) {
|
||||
fs.chmodSync(d, 0o755);
|
||||
}
|
||||
});
|
||||
|
||||
it("installs into the account's ~/.local when npm's global prefix is not writable", async (t) => {
|
||||
if (asRoot) return t.skip("root writes anywhere");
|
||||
versions.__setExec(async (_f, args) => {
|
||||
if (args[0] === "prefix") return { stdout: `${ro}\n` };
|
||||
throw new Error("unexpected");
|
||||
});
|
||||
assert.deepEqual(await jobs.stepsFor("fresh", "install"), [
|
||||
{
|
||||
argv: [path.join(shellBin, "npm"), "install", "-g", "--prefix", user, "fresh-pkg@latest"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("updates a tool under ~/.local/lib/node_modules with the same --prefix", async () => {
|
||||
const pkg = path.join(user, "lib", "node_modules", "fresh-pkg", "cli.js");
|
||||
exe(pkg);
|
||||
fs.mkdirSync(path.join(user, "bin"), { recursive: true });
|
||||
fs.symlinkSync(pkg, path.join(serverBin, "fresh"));
|
||||
aiTools.refresh();
|
||||
try {
|
||||
assert.equal(await versions.managedBy("fresh"), "user");
|
||||
assert.deepEqual(await jobs.stepsFor("fresh", "update"), [
|
||||
{
|
||||
argv: [path.join(shellBin, "npm"), "install", "-g", "--prefix", user, "fresh-pkg@latest"],
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(path.join(serverBin, "fresh"));
|
||||
aiTools.refresh();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("versions", () => {
|
||||
it("marks npm-managed by realpath and badges a newer version", async () => {
|
||||
const v = await versions.versions({ fresh: true });
|
||||
|
||||
@@ -206,7 +206,7 @@ describe("install / update / icons", () => {
|
||||
it("maps refusals to statuses", async () => {
|
||||
jobs.__reset();
|
||||
assert.equal((await request("POST", "/api/ai-tools/nope/install")).status, 404);
|
||||
const noInstall = await request("POST", "/api/ai-tools/antigravity/install");
|
||||
const noInstall = await request("POST", "/api/ai-tools/claude/install");
|
||||
assert.equal(noInstall.status, 400);
|
||||
assert.equal(noInstall.body.error.code, "ENOINSTALL");
|
||||
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @file Tests for the Artifacts hub (server/lib/artifacts.js, routes/artifacts.js):
|
||||
* claude.ai pages (link allowlist, hook capture, one row per URL), workspace
|
||||
* file candidates and pins (a snapshot, served sandboxed), skills (snapshot,
|
||||
* symlink/secret refusal, install into the caller's Claude home with
|
||||
* symlink/secret refusal, install into the caller's ~/.claude and ~/.agents skills with
|
||||
* replace moving the old one aside), plugins (marketplace allowlist, install
|
||||
* argv; a marketplace link read into its plugin list, the same plugin added
|
||||
* twice kept as one row, key/market edited), skills uploaded (one Markdown
|
||||
@@ -161,7 +161,7 @@ describe("files", () => {
|
||||
});
|
||||
|
||||
describe("skills", () => {
|
||||
it("snapshots a workspace skill and installs it into the caller's Claude home", async () => {
|
||||
it("snapshots a workspace skill and installs it into ~/.claude/skills and ~/.agents/skills", async () => {
|
||||
const list = await request("GET", `/api/artifacts/skills?laneId=${lane.id}`);
|
||||
assert.deepEqual(list.body.items, [{ name: "deploy", from: "workspace" }]);
|
||||
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "deploy" });
|
||||
@@ -169,11 +169,25 @@ describe("skills", () => {
|
||||
assert.equal(r.body.item.description, "Ship it");
|
||||
assert.deepEqual(r.body.item.files.map((f) => f.path).sort(), ["SKILL.md", "scripts/run.sh"]);
|
||||
const id = r.body.item.id;
|
||||
const listed = async () =>
|
||||
(await request("GET", "/api/artifacts?kind=skill")).body.items.find((i) => i.id === id);
|
||||
assert.equal((await listed()).installed, false);
|
||||
|
||||
const first = await request("POST", `/api/artifacts/${id}/install`, {});
|
||||
assert.equal(first.status, 200, first.text);
|
||||
assert.equal((await listed()).installed, true);
|
||||
const who = await request("GET", `/api/artifacts/${id}/installs`);
|
||||
assert.equal(who.status, 200, who.text);
|
||||
assert.equal(who.body.items.length, 1);
|
||||
assert.equal(who.body.items[0].mine, true);
|
||||
assert.deepEqual(who.body.items[0].paths, first.body.paths);
|
||||
assert.equal(who.body.unrecorded, 0);
|
||||
const target = path.join(process.env.CLAUDE_HOME, "skills", "deploy");
|
||||
assert.equal(fs.readFileSync(path.join(target, "scripts", "run.sh"), "utf8"), "echo hi\n");
|
||||
// The cross-tool folder beside the Claude home (Codex, Gemini, OpenCode…).
|
||||
const shared = path.join(ROOT, ".agents", "skills", "deploy");
|
||||
assert.equal(fs.readFileSync(path.join(shared, "scripts", "run.sh"), "utf8"), "echo hi\n");
|
||||
assert.deepEqual(first.body.paths, [target, shared]);
|
||||
|
||||
const again = await request("POST", `/api/artifacts/${id}/install`, {});
|
||||
assert.equal(again.status, 409);
|
||||
@@ -183,8 +197,35 @@ describe("skills", () => {
|
||||
assert.equal(replaced.status, 200, replaced.text);
|
||||
assert.ok(!fs.existsSync(path.join(target, "mine.txt")));
|
||||
const aside = path.join(process.env.CLAUDE_HOME, "agenthub-replaced-skills");
|
||||
const [old] = fs.readdirSync(aside);
|
||||
const old = fs.readdirSync(aside).find((n) => !n.includes("-agents-"));
|
||||
assert.equal(fs.readFileSync(path.join(aside, old, "mine.txt"), "utf8"), "keep me");
|
||||
assert.ok(fs.readdirSync(aside).some((n) => n.startsWith("deploy-agents-")));
|
||||
});
|
||||
|
||||
it("edits a skill's name, description and instructions into a new snapshot", async () => {
|
||||
const item = (await request("GET", "/api/artifacts?kind=skill")).body.items[0];
|
||||
const before = db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(item.id).snapshot;
|
||||
const r = await request("PATCH", `/api/artifacts/${item.id}`, {
|
||||
title: "Ship",
|
||||
skill: { name: "ship it", description: "Use to ship", content: "# Ship\n\nDo it." },
|
||||
});
|
||||
assert.equal(r.status, 200, r.text);
|
||||
assert.equal(r.body.item.title, "Ship");
|
||||
assert.equal(r.body.item.source, "ship-it");
|
||||
assert.equal(r.body.item.description, "Use to ship");
|
||||
// Other files are kept; the old snapshot is gone.
|
||||
assert.ok(r.body.item.files.some((f) => f.path === "scripts/run.sh"));
|
||||
const after = db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(item.id).snapshot;
|
||||
assert.notEqual(after, before);
|
||||
const md = await request("GET", `/api/artifacts/${item.id}/raw?path=SKILL.md`);
|
||||
assert.match(
|
||||
md.text,
|
||||
/^---\nname: ship-it\n[\s\S]*description: Use to ship\n---\n\n# Ship\n\nDo it\.$/
|
||||
);
|
||||
const empty = await request("PATCH", `/api/artifacts/${item.id}`, {
|
||||
skill: { name: "x", description: "d", content: " " },
|
||||
});
|
||||
assert.equal(empty.status, 400);
|
||||
});
|
||||
|
||||
it("refuses a skill holding a symlink or a secret-looking file", async () => {
|
||||
@@ -476,6 +517,46 @@ describe("sharing and visibility", () => {
|
||||
assert.equal(artifacts.canSeeId(dev, hidden), false);
|
||||
const shared = db.prepare("SELECT id FROM artifacts WHERE title = 'b'").get().id;
|
||||
assert.throws(() => artifacts.update(dev, shared, { title: "x" }), { code: "EFORBIDDEN" });
|
||||
|
||||
// Deleting a shared row from its owner's library keeps it in the Hub;
|
||||
// only unsharing it then deletes it.
|
||||
const mine = db
|
||||
.prepare(
|
||||
"INSERT INTO artifacts (kind, title, owner_id, url, shared) VALUES ('page', 'd', 7, 'https://claude.ai/d', 1)"
|
||||
)
|
||||
.run().lastInsertRowid;
|
||||
const kept = artifacts.remove(dev, mine);
|
||||
assert.equal(kept.kept, true);
|
||||
assert.ok(!artifacts.list(dev, { scope: "mine" }).some((i) => i.title === "d"));
|
||||
const hubRow = artifacts.list(dev, { scope: "hub" }).find((i) => i.title === "d");
|
||||
assert.equal(hubRow.inLibrary, false);
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
let gone;
|
||||
try {
|
||||
gone = artifacts.update(dev, mine, { shared: false });
|
||||
} finally {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
}
|
||||
assert.equal(gone.removed, true);
|
||||
assert.equal(db.prepare("SELECT id FROM artifacts WHERE id = ?").get(mine), undefined);
|
||||
|
||||
// Installs of someone else's shared row: a developer sees only their own,
|
||||
// its owner sees everyone's but not where another account wrote it.
|
||||
db.prepare("UPDATE artifacts SET installs = 3 WHERE id = ?").run(shared);
|
||||
const add = db.prepare(
|
||||
"INSERT INTO artifact_installs (artifact_id, user_id, paths) VALUES (?, ?, '[\"/h/x\"]')"
|
||||
);
|
||||
add.run(shared, 7);
|
||||
add.run(shared, 9);
|
||||
const own = artifacts.installsOf(dev, shared);
|
||||
assert.equal(own.items.length, 1);
|
||||
assert.deepEqual(own.items[0].paths, ["/h/x"]);
|
||||
assert.equal(own.unrecorded, 0);
|
||||
const owner = { method: "GET", user: { id: 8, role: "developer", username: "o" } };
|
||||
const seen = artifacts.installsOf(owner, shared);
|
||||
assert.equal(seen.items.length, 2);
|
||||
assert.ok(seen.items.every((i) => i.paths === undefined));
|
||||
assert.equal(seen.unrecorded, 1);
|
||||
});
|
||||
|
||||
it("refuses a cross-origin write", async () => {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
* @file Route tests for the workspace File Explorer (`/api/lanes/:id/fs/*`,
|
||||
* server/lib/file-explorer.js): list, mkdir/newfile, rename, move, copy (in
|
||||
* place, across workspaces), delete into the trash and restore/purge, a
|
||||
* streamed upload into new sub-folders with every conflict policy, a zip of
|
||||
* streamed upload into new sub-folders with every conflict policy, a batch
|
||||
* of many small files in one request (and, in a member's folder, as ONE
|
||||
* write-as `extract` of a tar that real `tar` unpacks), a zip of
|
||||
* files and folders that unzips back to the same bytes — and the guards: no
|
||||
* `..`, no symlink walking out, nothing in `.git`, no secret file, the
|
||||
* workspace folder itself never moved or deleted, cross-origin refused.
|
||||
@@ -220,6 +222,77 @@ describe("File Explorer", () => {
|
||||
assert.ok(!fs.existsSync(path.join(W1, "big.bin")));
|
||||
});
|
||||
|
||||
it("uploads many small files as one batch, planned whole before writing", async () => {
|
||||
const files = [
|
||||
["many/a.txt", "a"],
|
||||
["many/deep/b.txt", "bb"],
|
||||
["many/empty", ""],
|
||||
[`many/${"n".repeat(120)}/long.txt`, "long"],
|
||||
];
|
||||
const body = (list) => {
|
||||
const head = Buffer.from(
|
||||
JSON.stringify({ files: list.map(([p, c]) => ({ path: p, size: Buffer.byteLength(c) })) })
|
||||
);
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(head.length);
|
||||
return Buffer.concat([len, head, ...list.map(([, c]) => Buffer.from(c))]);
|
||||
};
|
||||
const batch = (list, conflict = "", dir = "up") =>
|
||||
request(
|
||||
"POST",
|
||||
`/api/lanes/${lane1}/fs/upload-batch?dir=${dir}${conflict ? `&conflict=${conflict}` : ""}`,
|
||||
body(list),
|
||||
{ "Content-Type": "application/octet-stream" }
|
||||
);
|
||||
assert.equal((await fsApi(lane1, "mkdir", { path: "up" })).status, 201);
|
||||
const check = await fsApi(lane1, "upload-check", { dir: "up", paths: files.map(([p]) => p) });
|
||||
assert.deepEqual(check.body, { taken: [], batch: true });
|
||||
const r = await batch(files);
|
||||
assert.equal(r.status, 201);
|
||||
assert.equal(r.body.saved, 4);
|
||||
for (const [p, c] of files) assert.equal(fs.readFileSync(path.join(W1, "up", p), "utf8"), c);
|
||||
// Taken names: one 409 naming them all, and nothing of the batch written.
|
||||
const again = await batch([
|
||||
["many/a.txt", "x"],
|
||||
["many/new.txt", "n"],
|
||||
["many/deep/b.txt", "y"],
|
||||
]);
|
||||
assert.equal(again.status, 409);
|
||||
assert.deepEqual(again.body.error.names, ["many/a.txt", "many/deep/b.txt"]);
|
||||
assert.ok(!fs.existsSync(path.join(W1, "up/many/new.txt")));
|
||||
const taken = await fsApi(lane1, "upload-check", { dir: "up", paths: ["many/a.txt", "z"] });
|
||||
assert.deepEqual(taken.body.taken, ["many/a.txt"]);
|
||||
// skip / keep / replace for the whole batch.
|
||||
const skip = await batch(
|
||||
[
|
||||
["many/a.txt", "x"],
|
||||
["many/new.txt", "n"],
|
||||
],
|
||||
"skip"
|
||||
);
|
||||
assert.deepEqual([skip.body.saved, skip.body.skipped], [1, 1]);
|
||||
const keep = await batch(
|
||||
[
|
||||
["many/a.txt", "k"],
|
||||
["many/a (1).txt", "k1"],
|
||||
],
|
||||
"keep"
|
||||
);
|
||||
assert.deepEqual(keep.body.paths, ["up/many/a (2).txt", "up/many/a (1).txt"]);
|
||||
await batch([["many/a.txt", "R"]], "replace");
|
||||
assert.equal(fs.readFileSync(path.join(W1, "up/many/a.txt"), "utf8"), "R");
|
||||
// The guards are upload's: no .git, no `..`, no file over a folder, no short body.
|
||||
assert.equal((await batch([[".git/hooks/x", "x"]], "", "")).status, 400);
|
||||
assert.equal((await batch([["../out", "x"]])).status, 400);
|
||||
assert.equal((await batch([["many", "x"]])).status, 409);
|
||||
const short = body([["s.txt", "12345"]]).subarray(0, -2);
|
||||
const cut = await request("POST", `/api/lanes/${lane1}/fs/upload-batch?dir=up`, short, {
|
||||
"Content-Type": "application/octet-stream",
|
||||
});
|
||||
assert.equal(cut.status, 400);
|
||||
assert.ok(!fs.existsSync(path.join(W1, "up/s.txt")));
|
||||
});
|
||||
|
||||
it("zips files and folders into one download that unzips to the same bytes", async () => {
|
||||
const r = await request("GET", `/api/lanes/${lane1}/fs/zip?p=src&p=site/css/app.css`);
|
||||
assert.equal(r.status, 200);
|
||||
@@ -286,6 +359,51 @@ describe("File Explorer in a member's folder (S3, run-as stubbed)", () => {
|
||||
assert.ok(fs.existsSync(path.join(M, "f.txt")), "the host itself moved nothing");
|
||||
});
|
||||
|
||||
it("writes a batch as ONE write-as extract whose tar real tar unpacks", async () => {
|
||||
const { spawn } = require("node:child_process");
|
||||
const prev = [runAs.writeAsAsync, runAs.writeAsCanExtract];
|
||||
const runs = [];
|
||||
runAs.writeAsCanExtract = () => true;
|
||||
runAs.writeAsAsync = (_a, file, args, { input }) => {
|
||||
runs.push([file, ...args]);
|
||||
return new Promise((resolve, reject) => {
|
||||
const tar = spawn("tar", ["-x", "-k", "-p", "--no-same-owner", "-f", "-", "-C", file]);
|
||||
let err = "";
|
||||
tar.stderr.on("data", (d) => (err += d));
|
||||
tar.on("close", (code) => (code ? reject(new Error(err)) : resolve()));
|
||||
input.pipe(tar.stdin);
|
||||
});
|
||||
};
|
||||
try {
|
||||
const long = `${"x".repeat(150)}/${"y".repeat(90)}.txt`;
|
||||
const list = [
|
||||
["b/one.txt", "1"],
|
||||
["b/sub/two.txt", "22"],
|
||||
[long, "L"],
|
||||
];
|
||||
const head = Buffer.from(
|
||||
JSON.stringify({ files: list.map(([p, c]) => ({ path: p, size: c.length })) })
|
||||
);
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(head.length);
|
||||
const { Readable } = require("node:stream");
|
||||
const out = await fx.uploadBatch(
|
||||
M,
|
||||
"d",
|
||||
Readable.from([Buffer.concat([len, head, ...list.map(([, c]) => Buffer.from(c))])])
|
||||
);
|
||||
assert.equal(out.saved, 3);
|
||||
assert.deepEqual(runs, [[path.join(M, "d"), "extract"]]);
|
||||
for (const [p, c] of list) assert.equal(fs.readFileSync(path.join(M, "d", p), "utf8"), c);
|
||||
assert.equal((fs.statSync(path.join(M, "d/b/one.txt")).mode & 0o777).toString(8), "644");
|
||||
// An old helper: the check says "no batch", the batch says EUPGRADE.
|
||||
runAs.writeAsCanExtract = () => false;
|
||||
assert.equal((await fx.uploadCheck(M, "d", ["z.txt"])).batch, false);
|
||||
} finally {
|
||||
[runAs.writeAsAsync, runAs.writeAsCanExtract] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it("says the helper needs updating instead of failing obscurely", async () => {
|
||||
runAs.writeAsCanMove = () => false;
|
||||
try {
|
||||
|
||||
@@ -332,6 +332,69 @@ describe("POST /api/run/drop-image", () => {
|
||||
});
|
||||
assert.equal(r.status, 400);
|
||||
});
|
||||
|
||||
it("refuses a file declared over the limit before its body arrives", async () => {
|
||||
// 4 GB announced, 1 KB sent: the answer must come without the rest.
|
||||
const { status, body } = await new Promise((resolve, reject) => {
|
||||
const url = new URL("/api/run/drop-file?name=big.mp4", BASE);
|
||||
const req = http.request(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "video/mp4", "Content-Length": 4 * 1024 ** 3 },
|
||||
});
|
||||
req.on("response", (res) => {
|
||||
let text = "";
|
||||
res.on("data", (c) => (text += c));
|
||||
res.on("end", () => {
|
||||
req.destroy();
|
||||
resolve({ status: res.statusCode, body: JSON.parse(text) });
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.write(Buffer.alloc(1024));
|
||||
});
|
||||
assert.equal(status, 413);
|
||||
assert.equal(body.error.code, "ETOOBIG");
|
||||
assert.equal(body.error.max_bytes, 20 * 1024 * 1024); // the UI shows the server's limit
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/run/local-paths", () => {
|
||||
let srv;
|
||||
before(async () => {
|
||||
srv = http.createServer(createApp());
|
||||
await new Promise((r) => srv.listen(0, "127.0.0.1", r));
|
||||
BASE = `http://127.0.0.1:${srv.address().port}`;
|
||||
});
|
||||
after(() => new Promise((r) => srv.close(r)));
|
||||
|
||||
it("answers the size of each regular file, null for anything else", async () => {
|
||||
const file = path.join(TMP, "clip.mp4");
|
||||
fs.writeFileSync(file, "12345");
|
||||
const r = await fetchJson("/api/run/local-paths", {
|
||||
method: "POST",
|
||||
body: { paths: [file, TMP, path.join(TMP, "missing.bin")] },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(r.body.sizes, [5, null, null]);
|
||||
});
|
||||
|
||||
it("refuses a relative path", async () => {
|
||||
const r = await fetchJson("/api/run/local-paths", {
|
||||
method: "POST",
|
||||
body: { paths: ["clip.mp4"] },
|
||||
});
|
||||
assert.equal(r.status, 400);
|
||||
});
|
||||
|
||||
it("refuses a request a proxy forwarded: that browser is elsewhere", async () => {
|
||||
const r = await fetchJson("/api/run/local-paths", {
|
||||
method: "POST",
|
||||
headers: { "X-Forwarded-For": "10.0.0.5" },
|
||||
body: { paths: [TMP] },
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(r.body.error.code, "EREMOTE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/run/drop/:name", () => {
|
||||
|
||||
@@ -18,7 +18,12 @@ const fs = require("node:fs");
|
||||
const TEST_DB = path.join(os.tmpdir(), `transcript-sender-${Date.now()}-${process.pid}.db`);
|
||||
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
||||
|
||||
const { classifyTranscriptSender, patchCounts, askAnswers } = require("../routes/sessions");
|
||||
const {
|
||||
classifyTranscriptSender,
|
||||
patchCounts,
|
||||
askAnswers,
|
||||
agentInfo,
|
||||
} = require("../routes/sessions");
|
||||
const { db } = require("../db");
|
||||
|
||||
after(() => {
|
||||
@@ -187,3 +192,44 @@ describe("askAnswers — what the user picked or typed for an AskUserQuestion",
|
||||
assert.equal(askAnswers("text"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agentInfo — what an Agent call's result says about its subagent", () => {
|
||||
it("reads a foreground agent's id, model, time, tokens and tool calls", () => {
|
||||
const r = {
|
||||
status: "completed",
|
||||
agentId: "a2f215755130779a8",
|
||||
resolvedModel: "claude-opus-5-5",
|
||||
totalDurationMs: 132350,
|
||||
totalTokens: 88007,
|
||||
totalToolUseCount: 31,
|
||||
content: [{ type: "text", text: "report" }],
|
||||
};
|
||||
assert.deepEqual(agentInfo(r), {
|
||||
id: "a2f215755130779a8",
|
||||
model: "claude-opus-5-5",
|
||||
status: "completed",
|
||||
duration_ms: 132350,
|
||||
tokens: 88007,
|
||||
tool_uses: 31,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves unknown facts null for a background launch", () => {
|
||||
const r = { isAsync: true, status: "async_launched", agentId: "ab12", description: "x" };
|
||||
assert.deepEqual(agentInfo(r), {
|
||||
id: "ab12",
|
||||
model: null,
|
||||
status: "async_launched",
|
||||
duration_ms: null,
|
||||
tokens: null,
|
||||
tool_uses: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("answers null for a result that is no agent, or an id that is no plain name", () => {
|
||||
assert.equal(agentInfo({ stdout: "ok" }), null);
|
||||
assert.equal(agentInfo({ agentId: "../etc" }), null);
|
||||
assert.equal(agentInfo("text"), null);
|
||||
assert.equal(agentInfo(undefined), null);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user