import { test } from "node:test"; import assert from "node:assert/strict"; import { connectWith, fakeEditor, isError, textOf } from "./editorOps.testkit"; import { enqueueBody, notesTarget, notesTool } from "./archivalTools"; // ─── The archival tools (release 19 A9), through tools/call ─── // // Each maps to one ops route on the fake editor (or umtool's notes routes); // what is pinned is the request sent and the words the agent reads. const EDITOR = "http://editor.test"; test("enqueue: each kind posts to its own route with only the keys it takes", () => { assert.deepEqual(enqueueBody({ kind: "sync", channel: "demo", full: true }), { ok: true, kind: "sync", route: "/api/ops/sync", body: { slug: "demo", full: true }, }); assert.deepEqual(enqueueBody({ kind: "retry-bucket", channel: "demo", bucket: "noTranscript", ids: ["a1"] }), { ok: true, kind: "retry-bucket", route: "/api/ops/retry-bucket", body: { slug: "demo", bucket: "noTranscript", ids: ["a1"] }, }); assert.deepEqual(enqueueBody({ kind: "import-video", channel: "demo", url: "https://example.invalid/v/1" }), { ok: true, kind: "import-video", route: "/api/ops/import-video", body: { slug: "demo", url: "https://example.invalid/v/1" }, }); const refusals: [Record, RegExp][] = [ [{ kind: "delete-channel", channel: "demo" }, /kind must be one of sync, download-missing/], [{ kind: "sync" }, /channel is required/], [{ kind: "sync", channel: "../x" }, /channel is required/], [{ kind: "download-missing", channel: "demo", ids: ["a"] }, /download-missing does not take ids$/], [{ kind: "sync", channel: "demo", url: "https://x" }, /sync does not take url \(it takes full\)/], [{ kind: "retry-bucket", channel: "demo" }, /retry-bucket needs bucket/], [{ kind: "transcribe-bucket", channel: "demo", ids: ["../a"] }, /ids must be a non-empty list of video ids/], [{ kind: "import-video", channel: "demo", url: "ftp://x" }, /import-video needs url/], [{ kind: "fetch-posts", channel: "demo", older: "yes" }, /older must be true or false/], ]; for (const [args, re] of refusals) { const r = enqueueBody(args); assert.equal(r.ok, false, JSON.stringify(args)); assert.match(!r.ok ? r.error : "", re, JSON.stringify(args)); } }); test("enqueue through tools/call: the job id comes back with how to follow it", async () => { const { deps, sent } = fakeEditor((s) => s.url.endsWith("/api/ops/transcribe-bucket") ? { status: 200, body: { ok: true, jobId: "j-77" } } : { status: 400, body: { ok: false, error: 'Channel "nope" not found' } }, ); const client = await connectWith(deps); const ok = await client.callTool({ name: "enqueue", arguments: { kind: "transcribe-bucket", channel: "demo" } }); assert.equal(isError(ok), false, textOf(ok)); assert.equal(sent[0].url, `${EDITOR}/api/ops/transcribe-bucket`); assert.equal(sent[0].method, "POST"); assert.deepEqual(sent[0].body, { slug: "demo" }); assert.match(textOf(ok), /queued as job j-77.*follow it with get_job \(job: "j-77"\)/s); const refused = await client.callTool({ name: "enqueue", arguments: { kind: "sync", channel: "nope" } }); assert.equal(isError(refused), true); assert.match(textOf(refused), /enqueue sync: the editor refused \(HTTP 400\): Channel "nope" not found/); const none = await (await connectWith(fakeEditor(() => ({ status: 200, body: {} }), {}).deps)).callTool({ name: "enqueue", arguments: { kind: "sync", channel: "demo" }, }); assert.match(textOf(none), /no editor configured/); }); test("get_job: status, queue place and the log tail", async () => { const { deps, sent } = fakeEditor((s) => s.url.includes("/api/ops/job/j-1") ? { status: 200, body: { ok: true, job: { id: "j-1", kind: "sync", channelSlug: "demo", status: "queued", queuedAt: Date.UTC(2026, 9, 10), queue: { key: "youtube", position: 2, queued: 3, head: { id: "j-0", kind: "persist-videos", channelSlug: "other" } }, }, tail: ["line one", "line two"], }, } : { status: 404, body: { ok: false, error: "no such job" } }, ); const client = await connectWith(deps); const res = await client.callTool({ name: "get_job", arguments: { job: "j-1", tail: 2 } }); assert.equal(new URL(sent[0].url).pathname, "/api/ops/job/j-1"); assert.equal(new URL(sent[0].url).searchParams.get("tail"), "2"); const t = textOf(res); assert.match(t, /job j-1: sync on demo — \*\*queued\*\*/); assert.match(t, /waiting on queue "youtube": position 2 of 3, behind persist-videos on other \(j-0\)/); assert.match(t, /Still going: call get_job again later/); assert.match(t, /```\nline one\nline two\n```/); const gone = await client.callTool({ name: "get_job", arguments: { job: "j-9" } }); assert.match(textOf(gone), /the editor knows no job j-9/); const bad = await client.callTool({ name: "get_job", arguments: { job: "j-1", tail: -1 } }); assert.match(textOf(bad), /tail is a whole number/); }); test("channel_coverage: the query sent, and the gaps read back", async () => { const { deps, sent } = fakeEditor(() => ({ status: 200, body: { ok: true, slug: "vod-mirror", titlePattern: "(?\\d{4})-(?\\d{2})-(?\\d{2})", held: 3, dated: 2, byRecordedDate: 2, byUploadDate: 0, undated: ["bare"], first: "20230501", last: "20240110", byYear: { "2023": 1, "2024": 1 }, byMonth: {}, gapDays: 60, gaps: [{ after: "20230501", before: "20240110", days: 254 }], }, })); const client = await connectWith(deps); const res = await client.callTool({ name: "channel_coverage", arguments: { channel: "vod-mirror", gap_days: 60, date_from: "2023-01-01", list: true }, }); const q = new URL(sent[0].url).searchParams; assert.equal(new URL(sent[0].url).pathname, "/api/ops/coverage"); assert.equal(q.get("slug"), "vod-mirror"); assert.equal(q.get("gapDays"), "60"); assert.equal(q.get("from"), "20230101"); assert.equal(q.get("list"), "1"); const t = textOf(res); assert.match(t, /3 held · 2 dated \(2 by recorded date, 0 by upload date\) · 1 undated · 2023-05-01 → 2024-01-10/); assert.match(t, /the recorded date read off each title/); assert.match(t, /- 2023-05-01 → 2024-01-10: 254 days with nothing held/); const bad = await client.callTool({ name: "channel_coverage", arguments: { channel: "x", date_to: "Jan 2024" } }); assert.match(textOf(bad), /date_to is a date, YYYY-MM-DD/); }); test("notes: list groups open notes by target; read fetches the digest; reply is the CLI command", async () => { const umtool = { UMTOOL_URL: "http://umtool.test/" }; const { deps, sent } = fakeEditor((s) => { if (s.url.includes("/api/browse/decisions")) { return { status: 200, body: { items: [ { kind: "open-note", project: "sites/demo-site/a-report", projectKind: "article", target: "cite c1", why: "say claimed" }, { kind: "cut-without-file", project: "song", target: "x", why: "y" }, { kind: "open-note", project: "demo/video-project", projectKind: "report-video", target: "take t2", why: "too long" }, ], }, }; } return { status: 200, body: "unused" }; }, umtool); const client = await connectWith(deps); const list = await client.callTool({ name: "notes", arguments: {} }); assert.equal(sent[0].url, "http://umtool.test/api/browse/decisions"); const t = textOf(list); assert.match(t, /2 open note\(s\) on 2 target\(s\)/); assert.match(t, /## sites\/demo-site\/a-report \(article\)/); assert.match(t, /- \[take t2\] too long/); assert.doesNotMatch(t, /song/); assert.deepEqual(notesTarget("sites/demo-site/a-report"), { param: "article", value: "demo-site/a-report" }); assert.deepEqual(notesTarget("demo/video-project"), { param: "project", value: "demo/video-project" }); assert.equal(notesTarget("sites/../x"), null); const digestSent: string[] = []; const read = await notesTool( { action: "read", target: "sites/demo-site/a-report", status: "all" }, { env: umtool, fetch: async (url) => { digestSent.push(url); return { status: 200, json: async () => ({}), text: async () => "# Notes on demo-site/a-report\n…" } as never; }, }, ); const q = new URL(digestSent[0]).searchParams; assert.equal(new URL(digestSent[0]).pathname, "/api/notes/context"); assert.equal(q.get("article"), "demo-site/a-report"); assert.equal(q.get("status"), "all"); assert.match(read.text, /^# Notes on demo-site\/a-report/); const reply = await client.callTool({ name: "notes", arguments: { action: "reply", note_id: "n_abc", text: "Changed it", resolve: true }, }); assert.equal(isError(reply), true); assert.match(textOf(reply), /umtool notes reply n_abc "Changed it" --resolve/); assert.equal(sent.length, 1, "a reply sends nothing"); const none = await notesTool({ action: "list" }, { env: {}, fetch: deps.fetch }); assert.match(none.text, /no umtool configured\. Set UMTOOL_URL/); });