Ctrl K
ComunidadGuías…

test/mcp-endpoint.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// POST /mcp and POST /api/jobs against a real Express server — the auth
// boundary between a person's token and one agent's session token, and the
// round trip that puts a card on the board.

import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest';
import express from 'express';
import { createServer } from 'http';
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { basename, join } from 'path';

const REPO = mkdtempSync(join(tmpdir(), 'a007-mcprepo-'));
const REPO2 = mkdtempSync(join(tmpdir(), 'a007-mcprepo2-'));
const USERS = join(mkdtempSync(join(tmpdir(), 'a007-mcpusers-')), 'users.json');
process.env.AGENT007_USERS_PATH = USERS;

const { config, sessions } = await import('../server/state.js');
const { setupRoutes } = await import('../server/http.js');
const { allJobs, boardSettings, updateSettings, closeJobForAgent } = await import('../server/jobs.js');
const { mintAgentToken, hashToken } = await import('../server/auth.js');
const { dropMessages } = await import('../server/messages.js');

const AGENT_TOKEN = mintAgentToken();
const broadcasts = [];
const killed = [];
let baseUrl;

const server = createServer((() => {
  const app = express();
  setupRoutes(app, mkdtempSync(join(tmpdir(), 'a007-static-')), {
    broadcast: (msg) => broadcasts.push(msg),
    killSession: async (id) => { killed.push(id); },
  });
  return app;
})());

await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
baseUrl = `http://127.0.0.1:${server.address().port}`;

afterAll(() => {
  server.close();
  rmSync(USERS, { force: true });
});

// No users file => auth disabled, which is the default single-player shape.
function noUsers() {
  rmSync(USERS, { force: true });
}

function withUser() {
  const token = 'usertoken-abcdefghijklmnop';
  writeFileSync(USERS, JSON.stringify([
    { id: 'u1', displayName: 'Bill', tokenHash: hashToken(token), color: '#fff' },
  ]));
  return token;
}

beforeEach(() => {
  config.repos = [{ path: REPO }, { path: REPO2 }];
  config.jobs = [];
  config.jobBoard = null;
  boardSettings();
  sessions.clear();
  sessions.set('session-1', {
    id: 'session-1', name: 'Onyx', repoPath: REPO,
    ownerId: null, exited: false, agentToken: AGENT_TOKEN,
  });
  broadcasts.length = 0;
  noUsers();
});

const rpc = (body, token = AGENT_TOKEN) => fetch(`${baseUrl}/mcp`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    ...(token ? { Authorization: `Bearer ${token}` } : {}),
  },
  body: JSON.stringify(body),
});

const callNamed = (name, args, token = AGENT_TOKEN) => rpc({
  jsonrpc: '2.0', id: 1, method: 'tools/call',
  params: { name, arguments: args },
}, token);

const callTool = (args, token = AGENT_TOKEN) => callNamed('post_job', args, token);

// What an agent actually reads back: the text, and whether it was a refusal.
// One read of the body — a Response can only be consumed once.
const toolResult = async (res) => {
  const { result } = await res.json();
  return { text: result.content[0].text, failed: result.isError };
};
const toolText = async (res) => (await toolResult(res)).text;

describe('who may reach /mcp', () => {
  it('turns away a caller with no token, even with auth off', () => {
    // Unlike /api, this endpoint is not relaxed in single-player: the token is
    // not keeping strangers out of loopback, it is the only thing that says
    // WHICH agent is calling. Without it there is nobody to attribute a card to.
    return rpc({ jsonrpc: '2.0', id: 1, method: 'tools/list' }, null)
      .then(res => expect(res.status).toBe(401));
  });

  it('turns away a token belonging to a session that has exited', async () => {
    sessions.get('session-1').exited = true;
    expect((await rpc({ jsonrpc: '2.0', id: 1, method: 'tools/list' })).status).toBe(401);
  });

  it('turns away a token for a session that is simply gone', async () => {
    sessions.clear();
    expect((await rpc({ jsonrpc: '2.0', id: 1, method: 'tools/list' })).status).toBe(401);
  });

  it('does not accept a user token here', async () => {
    const userToken = withUser();
    expect((await rpc({ jsonrpc: '2.0', id: 1, method: 'tools/list' }, userToken)).status).toBe(401);
  });

  it('rejects a cross-origin call from a browser page', async () => {
    const res = await fetch(`${baseUrl}/mcp`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${AGENT_TOKEN}`,
        Origin: 'https://evil.example',
      },
      body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
    });
    expect(res.status).toBe(403);
  });
});

describe('the handshake over HTTP', () => {
  it('answers initialize and lists the tool', async () => {
    const init = await (await rpc({ jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2025-06-18' } })).json();
    expect(init.result.serverInfo.name).toBe('agent-007-board');

    const list = await (await rpc({ jsonrpc: '2.0', id: 1, method: 'tools/list' })).json();
    expect(list.result.tools.map(t => t.name)).toEqual(['post_job', 'list_jobs', 'read_job', 'edit_job', 'finish_job', 'list_agents', 'send_message', 'withdraw_message']);
  });

  it('answers a notification with 202 and an empty body', async () => {
    const res = await rpc({ jsonrpc: '2.0', method: 'notifications/initialized' });
    expect(res.status).toBe(202);
    expect(await res.text()).toBe('');
  });

  it('rejects an oversized body as JSON with a 413', async () => {
    // A detail longer than the cap is the realistic way to hit this — an agent
    // piping a whole log file into the tool. Express's default handler would
    // answer an API client with an HTML error page.
    const res = await fetch(`${baseUrl}/mcp`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${AGENT_TOKEN}` },
      body: JSON.stringify({
        jsonrpc: '2.0', id: 1, method: 'tools/call',
        params: { name: 'post_job', arguments: { title: 'x', detail: 'y'.repeat(200_000) } },
      }),
    });
    expect(res.status).toBe(413);
    expect((await res.json()).error).toMatch(/too large/i);
    expect(allJobs()).toHaveLength(0);
  });

  it('answers a malformed body as JSON, not as an HTML error page', async () => {
    const res = await fetch(`${baseUrl}/mcp`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${AGENT_TOKEN}` },
      body: '{not json',
    });
    expect(res.status).toBe(400);
    expect((await res.json()).error).toMatch(/Invalid JSON/);
  });
});

describe('posting a job through the tool', () => {
  it('lands a card in To do, credited to the agent that typed it', async () => {
    const res = await callTool({ title: 'Add rate limiting', detail: 'Token bucket.' });
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.result.isError).toBe(false);

    expect(allJobs()).toHaveLength(1);
    const job = allJobs()[0];
    expect(job.title).toBe('Add rate limiting');
    expect(job.state).toBe('todo');
    expect(job.postedByAgent).toBe('Onyx');
    expect(job.repoPath).toBe(REPO);
  });

  it('defaults to the repo that agent is working in', async () => {
    await callTool({ title: 'No repo named' });
    expect(allJobs()[0].repoPath).toBe(REPO);
  });

  it('reaches another configured repo by folder name', async () => {
    // Deliberate: an agent noticing work elsewhere is the case this exists for.
    await callTool({ title: 'Elsewhere', repo: basename(REPO2) });
    expect(allJobs()[0].repoPath).toBe(REPO2);
  });

  it('refuses a repo the board does not know, instead of queueing a job that never runs', async () => {
    const body = await (await callTool({ title: 'Nowhere', repo: '/not/a/repo' })).json();
    expect(body.result.isError).toBe(true);
    expect(body.result.content[0].text).toMatch(/Unknown repository/);
    expect(allJobs()).toHaveLength(0);
  });

  it('refuses an empty title', async () => {
    const body = await (await callTool({ title: '   ' })).json();
    expect(body.result.isError).toBe(true);
    expect(allJobs()).toHaveLength(0);
  });

  it('does not stringify a non-string title into the card', async () => {
    // This body comes off the wire; {} would otherwise land as "[object Object]".
    const body = await (await callTool({ title: { nope: 1 } })).json();
    expect(body.result.isError).toBe(true);
    expect(allJobs()).toHaveLength(0);
  });

  it('carries postedByAgent out to the client on the board payload', async () => {
    // The seam between the stored job and the rendered card. jobsPayload spreads
    // the job today, but if it ever grows a field whitelist the "via <agent>"
    // credit would vanish silently and both other tests would still pass.
    await callTool({ title: 'Add rate limiting' });
    const payload = broadcasts.find(m => m.type === 'jobs-list');
    expect(payload.jobs[0].postedByAgent).toBe('Onyx');
  });

  it('repaints every open board and announces the card', async () => {
    // The user may be looking at a terminal, not the board tab.
    await callTool({ title: 'Add rate limiting' });
    expect(broadcasts.some(m => m.type === 'jobs-list')).toBe(true);
    const note = broadcasts.find(m => m.type === 'notification');
    expect(note.message).toContain('Onyx posted a job');
  });

  it('warns the agent when the dispatcher is stopped', async () => {
    updateSettings({ running: false }, () => {});
    const body = await (await callTool({ title: 'Queued but idle' })).json();
    expect(body.result.content[0].text).toMatch(/dispatcher is stopped/);
  });

  it('credits the human who owns the terminal', async () => {
    withUser();
    sessions.get('session-1').ownerId = 'u1';
    await callTool({ title: 'On behalf of Bill' });
    const job = allJobs()[0];
    expect(job.postedByName).toBe('Bill');
    expect(job.postedByAgent).toBe('Onyx');
  });
});

describe('POST /api/jobs — the door for agents that cannot take an MCP server', () => {
  const post = (body, token) => fetch(`${baseUrl}/api/jobs`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
    body: JSON.stringify(body),
  });

  it('accepts an agent token and returns the created card', async () => {
    const res = await post({ title: 'From a plain HTTP agent' }, AGENT_TOKEN);
    expect(res.status).toBe(201);
    const body = await res.json();
    expect(body.job.title).toBe('From a plain HTTP agent');
    expect(body.repoName).toBe(basename(REPO));
    expect(allJobs()).toHaveLength(1);
  });

  it('rejects a bad repo with a 400 rather than a tool-shaped error', async () => {
    const res = await post({ title: 'x', repo: '/nope' }, AGENT_TOKEN);
    expect(res.status).toBe(400);
  });

  it('needs a credential once users exist', async () => {
    withUser();
    expect((await post({ title: 'x' }, null)).status).toBe(401);
  });

  it('carries the agent field through, and refuses one it does not know with a 400', async () => {
    // No schema at this door, unlike the MCP tool's enum, so the allowlist in
    // lib/jobs.js is the only thing between the body and the argv.
    const res = await post({ title: 'On codex', agent: 'codex' }, AGENT_TOKEN);
    expect(res.status).toBe(201);
    expect((await res.json()).job.agent).toBe('codex');
    const bad = await post({ title: 'x', agent: 'gemini' }, AGENT_TOKEN);
    expect(bad.status).toBe(400);
    expect((await bad.json()).error).toMatch(/Unknown agent/);
    expect(allJobs()).toHaveLength(1);
  });
});

describe('the CLI a posted card runs on', () => {
  it('defaults to the calling session\'s own, and an explicit agent overrides it', async () => {
    sessions.get('session-1').command = 'codex --model o3';
    await callTool({ title: 'Same as me' });
    await callTool({ title: 'But this one on claude', agent: 'claude' });
    expect(allJobs().map(j => j.agent)).toEqual(['codex', 'claude']);
  });

  it('is readable back through list_jobs and read_job, and only when it is codex', async () => {
    sessions.get('session-1').command = 'codex';
    await callTool({ title: 'On codex' });
    await callTool({ title: 'On claude', agent: 'claude' });
    const [codex, claude] = allJobs();
    const list = await toolText(await callNamed('list_jobs', {}));
    expect(list).toMatch(/On codex\n\s+\S+ · codex/);
    expect(list).not.toMatch(/On claude\n\s+\S+ · codex/);
    expect(await toolText(await callNamed('read_job', { id: codex.id }))).toContain('runs on: codex');
    expect(await toolText(await callNamed('read_job', { id: claude.id }))).not.toContain('runs on:');
  });
});

describe('the agent token reaches nothing else', () => {
  it('cannot browse the filesystem', async () => {
    // The gate ordering is the access-control decision: /api/browse sits below
    // requireUser, so an agent token is identified and then refused. If this
    // ever returns 200, a route has drifted above the gate.
    withUser();
    const res = await fetch(`${baseUrl}/api/browse?path=${tmpdir()}`, {
      headers: { Authorization: `Bearer ${AGENT_TOKEN}` },
    });
    expect(res.status).toBe(403);
    expect((await res.json()).error).toMatch(/needs a user token/);
  });

  it('still tells an anonymous caller they are unauthenticated, not forbidden', async () => {
    withUser();
    expect((await fetch(`${baseUrl}/api/browse?path=${tmpdir()}`)).status).toBe(401);
  });

  it('lets a real user through', async () => {
    const userToken = withUser();
    const res = await fetch(`${baseUrl}/api/browse?path=${tmpdir()}`, {
      headers: { Authorization: `Bearer ${userToken}` },
    });
    expect(res.status).toBe(200);
  });
});

describe('reading and editing the board through the tools', () => {
  // The ids are generated, so every test here starts by posting through the
  // same door an agent would and reading the id back off the board.
  const post = async (args) => {
    await callTool(args);
    return allJobs()[allJobs().length - 1];
  };

  it('lists the board grouped by column, with the id needed to act on a card', async () => {
    const todo = await post({ title: 'Add rate limiting' });
    const running = await post({ title: 'Already going' });
    running.state = 'in-progress';
    running.agentName = 'Slate';

    const text = await toolText(await callNamed('list_jobs', {}));
    expect(text).toMatch(/2 card\(s\)/);
    expect(text).toMatch(/To do \(1\)[\s\S]*Add rate limiting/);
    expect(text).toMatch(/In progress \(1\)[\s\S]*Already going/);
    expect(text).toContain(todo.id);
    expect(text).toContain('Slate');
  });

  it('leaves finished cards off the board but says they are there', async () => {
    const done = await post({ title: 'Long since merged' });
    done.state = 'done';

    const board = await toolText(await callNamed('list_jobs', {}));
    expect(board).not.toContain('Long since merged');
    expect(board).toMatch(/1 finished card\(s\) are archived/);

    const archive = await toolText(await callNamed('list_jobs', { state: 'done' }));
    expect(archive).toContain('Long since merged');
  });

  it('filters by repo, by folder name, the way posting does', async () => {
    await post({ title: 'Here' });
    await post({ title: 'Elsewhere', repo: basename(REPO2) });
    const text = await toolText(await callNamed('list_jobs', { repo: basename(REPO2) }));
    expect(text).toContain('Elsewhere');
    expect(text).not.toContain('Here');
  });

  it('refuses a state that is not a column, rather than answering with nothing', async () => {
    const { text, failed } = await toolResult(await callNamed('list_jobs', { state: 'backlog' }));
    expect(failed).toBe(true);
    expect(text).toMatch(/Unknown state/);
  });

  it('reads one card back in full, detail and all', async () => {
    const job = await post({ title: 'Add rate limiting', detail: 'Token bucket, 100/min.' });
    job.branchName = 'board/add-rate-limiting';
    job.lastError = 'worktree busy';

    const text = await toolText(await callNamed('read_job', { id: job.id }));
    expect(text).toContain('Token bucket, 100/min.');
    expect(text).toContain('board/add-rate-limiting');
    expect(text).toContain('worktree busy');
    expect(text).toMatch(/column: To do/);
  });

  it('hands back an unknown id as a tool error naming what to do', async () => {
    const { text, failed } = await toolResult(await callNamed('read_job', { id: 'job-nope' }));
    expect(failed).toBe(true);
    expect(text).toMatch(/No job with id .*list the board/);
  });

  it('edits a To do card and repaints every open board', async () => {
    const job = await post({ title: 'Add rate limiting', detail: 'Token bucket.' });
    broadcasts.length = 0;

    const text = await toolText(await callNamed('edit_job', {
      id: job.id, title: 'Add rate limiting to /api', detail: 'Token bucket, 100/min.',
    }));
    expect(text).toMatch(/Updated title, detail/);
    // A one-time card gets no "It runs ..." tail — that only applies to a
    // scheduled edit.
    expect(text).not.toContain('It runs');
    expect(allJobs()[0].title).toBe('Add rate limiting to /api');
    expect(allJobs()[0].detail).toBe('Token bucket, 100/min.');
    expect(broadcasts.some(m => m.type === 'jobs-list')).toBe(true);
  });

  it('leaves the fields it was not given alone', async () => {
    const job = await post({ title: 'Add rate limiting', detail: 'Token bucket.' });
    await callNamed('edit_job', { id: job.id, title: 'Retitled' });
    expect(allJobs()[0].detail).toBe('Token bucket.');
    expect(allJobs()[0].repoPath).toBe(REPO);
  });

  it('turns a card into a scheduled one and back again', async () => {
    const job = await post({ title: 'Sweep the logs' });
    await callNamed('edit_job', { id: job.id, schedule: '@daily' });
    expect(allJobs()[0].type).toBe('scheduled');
    expect(allJobs()[0].nextRunAt).toBeTruthy();

    await callNamed('edit_job', { id: job.id, schedule: '' });
    expect(allJobs()[0].type).toBe('one-time');
    expect(allJobs()[0].schedule).toBeNull();
    expect(allJobs()[0].nextRunAt).toBeNull();
  });

  it('refuses to edit a card that has left To do, and changes nothing', async () => {
    // The whole rule: the agent working this card was handed its text at
    // dispatch, so a later edit would leave the card describing work nobody
    // was asked to do.
    for (const state of ['in-progress', 'review', 'done']) {
      const job = await post({ title: `Gone to ${state}`, detail: 'Original.' });
      job.state = state;

      const { text, failed } = await toolResult(
        await callNamed('edit_job', { id: job.id, title: 'Rewritten', detail: 'Rewritten.' }));
      expect(failed, state).toBe(true);
      expect(text, state).toMatch(/only cards still in To do can be edited/);
      expect(job.title, state).toBe(`Gone to ${state}`);
      expect(job.detail, state).toBe('Original.');
    }
  });

  it('refuses an edit that changes nothing, rather than reporting a save', async () => {
    const job = await post({ title: 'Add rate limiting' });
    const { text, failed } = await toolResult(
      await callNamed('edit_job', { id: job.id, title: 'Add rate limiting' }));
    expect(failed).toBe(true);
    expect(text).toMatch(/Nothing to change/);
  });

  it('refuses to blank a title', async () => {
    const job = await post({ title: 'Add rate limiting' });
    const { failed } = await toolResult(await callNamed('edit_job', { id: job.id, title: '   ' }));
    expect(failed).toBe(true);
    expect(allJobs()[0].title).toBe('Add rate limiting');
  });

  it('turns a user token away from the read and edit tools too', async () => {
    // /mcp is the agent door: a person reads and edits through the board's own
    // API, and their token does not resolve here at all.
    const userToken = withUser();
    for (const name of ['list_jobs', 'read_job', 'edit_job', 'finish_job', 'list_agents', 'send_message']) {
      expect((await callNamed(name, { id: 'job-1' }, userToken)).status, name).toBe(401);
    }
  });

  it('will not let an agent rewrite a card someone else queued', async () => {
    // A To do card's detail is the next agent's prompt, handed to an unattended
    // run. Every board agent holds one of these tokens, so an agent in one repo
    // must not be able to rewrite what runs in another person's.
    withUser();
    const job = await post({ title: 'Theirs', detail: 'Original.' });
    job.postedBy = 'someone-else';
    job.postedByName = 'Ada';

    const { text, failed } = await toolResult(
      await callNamed('edit_job', { id: job.id, detail: 'Do something else entirely.' }));
    expect(failed).toBe(true);
    expect(text).toMatch(/was queued by Ada/);
    expect(job.detail).toBe('Original.');
  });

  it('leaves an unowned card alone on a single-player board', async () => {
    // No users file: postedBy is null on every card and the agent has no owner
    // either, so the ownership rule must not fire at all.
    const job = await post({ title: 'Mine', detail: 'Original.' });
    expect(job.postedBy).toBeNull();
    const { failed } = await toolResult(await callNamed('edit_job', { id: job.id, detail: 'Updated.' }));
    expect(failed).toBeFalsy();
    expect(allJobs()[0].detail).toBe('Updated.');
  });

  it('says out loud that an agent edited a card, and leaves its name on it', async () => {
    // The same reasoning as the toast on a posted card, and more so: an edit
    // destroys text rather than adding a card, and the card otherwise still
    // reads as the work of whoever queued it.
    const job = await post({ title: 'Add rate limiting' });
    broadcasts.length = 0;
    await callNamed('edit_job', { id: job.id, detail: 'Token bucket, 100/min.' });

    const toast = broadcasts.find(m => m.type === 'notification');
    expect(toast.message).toMatch(/Onyx edited a job: "Add rate limiting"/);
    expect(allJobs()[0].editedByAgent).toBe('Onyx');
    expect(allJobs()[0].editedAt).toBeTruthy();
    expect(await toolText(await callNamed('read_job', { id: job.id }))).toMatch(/edited by Onyx on /);
  });

  it('refuses a repo filter that is not a string, rather than listing everything', async () => {
    await post({ title: 'Here' });
    const { text, failed } = await toolResult(await callNamed('list_jobs', { repo: 42 }));
    expect(failed).toBe(true);
    expect(text).toMatch(/repo must be a string/);
  });

  it('lists the board in the order the board itself shows', async () => {
    const first = await post({ title: 'Queued first' });
    const second = await post({ title: 'Queued second' });
    // Stored out of order: the reply is sorted by when each was posted, the
    // way the board's To do column is.
    first.postedAt = '2026-09-03T10:00:00.000Z';
    second.postedAt = '2026-09-03T09:00:00.000Z';
    const text = await toolText(await callNamed('list_jobs', {}));
    expect(text.indexOf('Queued second')).toBeLessThan(text.indexOf('Queued first'));
  });

  it('moves a card to another repository by folder name', async () => {
    const job = await post({ title: 'Move me' });
    const text = await toolText(await callNamed('edit_job', { id: job.id, repo: basename(REPO2) }));
    expect(text).toMatch(/Updated repo on "Move me"/);
    expect(allJobs()[0].repoPath).toBe(REPO2);
  });

  it('refuses to move a card to an unknown repository, and leaves it in place', async () => {
    const job = await post({ title: 'Stay put' });
    const { text, failed } = await toolResult(await callNamed('edit_job', { id: job.id, repo: '/nope' }));
    expect(failed).toBe(true);
    expect(text).toMatch(/Unknown repository/);
    expect(allJobs()[0].repoPath).toBe(REPO);
  });

  it("treats resending the card's current repo as no change", async () => {
    const job = await post({ title: 'Stay put' });
    const { text, failed } = await toolResult(await callNamed('edit_job', { id: job.id, repo: basename(REPO) }));
    expect(failed).toBe(true);
    expect(text).toMatch(/Nothing to change/);
  });

  it('refuses a non-string detail or schedule from the wire', async () => {
    const job = await post({ title: 'Add rate limiting' });
    let r = await toolResult(await callNamed('edit_job', { id: job.id, detail: 5 }));
    expect(r.failed).toBe(true);
    expect(r.text).toMatch(/detail must be a string/);
    r = await toolResult(await callNamed('edit_job', { id: job.id, schedule: 5 }));
    expect(r.failed).toBe(true);
    expect(r.text).toMatch(/schedule must be a string/);
  });

  it('propagates a bad cron from updateJob as a tool error, unapplied', async () => {
    const job = await post({ title: 'Digest' });
    const { text, failed } = await toolResult(
      await callNamed('edit_job', { id: job.id, schedule: 'every friday' }));
    expect(failed).toBe(true);
    expect(text).toMatch(/five fields/i);
    expect(allJobs()[0].type).toBe('one-time');
  });

  it('hands an unknown card id to edit_job back as a tool error', async () => {
    const { text, failed } = await toolResult(await callNamed('edit_job', { id: 'job-nope', title: 'X' }));
    expect(failed).toBe(true);
    expect(text).toMatch(/No job with id/);
  });

  it('answers a no-op edit on a dispatched card with the To do rule, not "nothing to change"', async () => {
    // The gate runs before the fields are diffed, so a same-value edit to a
    // card that has left To do reports the rule that actually stopped it.
    const job = await post({ title: 'Add rate limiting' });
    job.state = 'in-progress';
    const { text, failed } = await toolResult(
      await callNamed('edit_job', { id: job.id, title: 'Add rate limiting' }));
    expect(failed).toBe(true);
    expect(text).toMatch(/only cards still in To do can be edited/);
    expect(text).not.toMatch(/Nothing to change/);
  });

  it('reports nothing archived when a column filter is applied, even with finished cards on the board', async () => {
    const done = await post({ title: 'Wrapped up' });
    done.state = 'done';
    const text = await toolText(await callNamed('list_jobs', { state: 'todo' }));
    expect(text).not.toMatch(/archived/i);
  });

  it('refuses to list an unknown repository too', async () => {
    const { text, failed } = await toolResult(await callNamed('list_jobs', { repo: '/nope' }));
    expect(failed).toBe(true);
    expect(text).toMatch(/Unknown repository/);
  });

  it('shows the schedule and pull request in the one-line list summary', async () => {
    const job = await post({ title: 'Nightly sweep' });
    Object.assign(job, {
      type: 'scheduled', schedule: '@daily', nextRunAt: '2026-09-04T09:00:00.000Z',
      prUrl: 'https://github.com/x/y/pull/9',
    });
    const text = await toolText(await callNamed('list_jobs', {}));
    expect(text).toMatch(/schedule @daily, next/);
    expect(text).toContain('https://github.com/x/y/pull/9');
  });

  it('reads the agent, schedule and pull request fields when a card carries them', async () => {
    const job = await post({ title: 'Nightly sweep' });
    Object.assign(job, {
      state: 'in-progress',
      type: 'scheduled', schedule: '@daily', nextRunAt: '2026-09-04T09:00:00.000Z',
      runCount: 3, lastRunAt: '2026-09-01T09:00:00.000Z',
      agentName: 'Slate', startedAt: '2026-09-01T08:00:00.000Z',
      worktreePath: '/wt/9',
      prUrl: 'https://github.com/x/y/pull/9', prMergedAt: '2026-09-02T00:00:00.000Z',
      prCheckError: 'checks failing',
    });
    const text = await toolText(await callNamed('read_job', { id: job.id }));
    expect(text).toMatch(/schedule: @daily.*posted 3 run\(s\)/);
    expect(text).toMatch(/agent: Slate, started/);
    expect(text).toMatch(/pull request: https:\/\/github\.com\/x\/y\/pull\/9 \(merged/);
    expect(text).toMatch(/pull request check: checks failing/);
    // Never the absolute paths: this reply goes to whatever agent asked, about
    // every card on the board, and those describe the user's disk.
    expect(text).not.toContain('/wt/9');
    expect(text).not.toContain(REPO);
    expect(text).toContain(basename(REPO));
  });
});

describe('schedules and their runs through the tools', () => {
  it('reads a PR-run schedule\'s last hold-off, and names the schedule a run belongs to', async () => {
    await callTool({ title: 'Bump deps', schedule: '@daily', requires_pr: true });
    await callTool({ title: 'Bump deps run' });
    const [schedule, run] = allJobs();
    Object.assign(schedule, { lastSkipReason: 'waiting on PR #7' });
    Object.assign(run, { scheduleId: schedule.id, requiresPr: false });
    const read = await toolText(await callNamed('read_job', { id: schedule.id }));
    expect(read).toMatch(/each run opens a pull request/);
    expect(read).toMatch(/last held off: waiting on PR #7/);
    const runText = await toolText(await callNamed('read_job', { id: run.id }));
    expect(runText).toContain(`(a run of schedule ${schedule.id})`);
    expect(await toolText(await callNamed('list_jobs', {}))).toMatch(/a scheduled run/);
  });
});

describe('finishing a no-PR job through the tool', () => {
  it('posts it, lets its own agent finish it with a summary, and reads the result back', async () => {
    const posted = await toolText(await callTool({ title: 'Why is pty.js leaking', requires_pr: false }));
    expect(posted).toMatch(/\(To do, no pull request\)/);
    const job = allJobs()[0];
    expect(job.requiresPr).toBe(false);
    // As if the board had dispatched it to session-1.
    Object.assign(job, { state: 'in-progress', agentSessionId: 'session-1', agentName: 'Onyx', branchName: 'board/why' });
    const done = await toolResult(await callNamed('finish_job', { summary: 'An unclosed onData listener.' }));
    expect(done.failed).toBeFalsy();
    expect(done.text).toMatch(/is in Review/);
    expect(job.state).toBe('review');
    const text = await toolText(await callNamed('read_job', { id: job.id }));
    expect(text).toContain('schedule: runs once, no pull request');
    expect(text).toContain('result: An unclosed onData listener.');
  });
});

describe('messaging another agent through the tools', () => {
  // The route builds list_agents' rows and binds send_message to the calling
  // session; mcp-protocol and messages.test.js stub both, so only this proves
  // the wiring picks the right sender and does not hand a session over whole.
  const peer = (id, name, fields = {}) => ({
    id, name, command: 'codex', agent: 'codex', repoSlug: 'agent-007', branchName: `b-${name}`,
    state: 'WAITING', stateChangedAt: 0, isTUI: true, lastOutputAt: 0, recentStrippedLines: [], ownerId: null, exited: false,
    agentToken: `secret-${id}`, pty: { write: vi.fn() }, ...fields,
  });

  beforeEach(() => {
    for (const id of ['session-1', 'session-2', 'session-3', 'session-4']) dropMessages(id);
    sessions.get('session-1').command = 'claude';
  });

  it('lists other agents with their job title and backlog, and not shell tabs', async () => {
    config.jobs = [{ id: 'job-9', title: 'Fix cron', repoPath: REPO, state: 'running' }];
    sessions.set('session-2', peer('session-2', 'Viper', { state: 'WORKING', jobId: 'job-9' }));
    sessions.set('session-3', peer('session-3', 'Asp', { command: 'bash' }));
    await callNamed('send_message', { to: 'Viper', message: 'hi' });
    const text = await toolText(await callNamed('list_agents', {}));
    expect(text).toContain('1 agent(s) you can message');
    expect(text).toContain('Viper\n    codex · agent-007 · b-Viper · working · job: Fix cron · 1 message(s) waiting');
    expect(text).not.toContain('Asp');
    expect(text).not.toContain('Onyx');           // never itself
    expect(text).not.toContain('secret');
  });

  it('types the message into the named agent, signed by the caller', async () => {
    const viper = peer('session-2', 'Viper');
    sessions.set('session-2', viper);
    const { text, failed } = await toolResult(await callNamed('send_message', { to: 'Viper', message: 'can you rebase?' }));
    expect(failed).toBeFalsy();
    expect(text).toMatch(/^Delivered to Viper/);
    const typed = () => viper.pty.write.mock.calls.map(c => c[0]).join('');
    expect(typed()).toContain('[Message from agent Onyx]');
    await vi.waitFor(() => expect(typed()).toContain('can you rebase?'));   // typed in small pastes
  });

  it('refuses an agent owned by someone else, as a tool error', async () => {
    sessions.get('session-1').ownerId = 'u1';
    const theirs = peer('session-4', 'Mamba', { ownerId: 'u2' });
    sessions.set('session-4', theirs);
    const { text, failed } = await toolResult(await callNamed('send_message', { to: 'Mamba', message: 'hi' }));
    expect(failed).toBe(true);
    expect(text).toMatch(/No agent named "Mamba"/);
    expect(theirs.pty.write).not.toHaveBeenCalled();
  });
});

describe('notify_owner through the route', () => {
  it('files Billion\'s question with the project and type it named', async () => {
    const { waitingItems } = await import('../server/owner.js');
    sessions.get('session-1').isBillion = true;
    await callNamed('notify_owner', { text: 'Ship it?', project: 'general', type: 'product' });
    expect(waitingItems().at(-1)).toMatchObject({ text: 'Ship it?', project: 'general', type: 'product' });
  });
});

describe('a card\'s poster over HTTP: read, close, message', () => {
  const OTHER_TOKEN = mintAgentToken();
  const call = (method, path, body, token) => fetch(`${baseUrl}${path}`, {
    method,
    headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
    ...(body ? { body: JSON.stringify(body) } : {}),
  });
  const postCard = async (token = AGENT_TOKEN, fields = {}) => {
    const res = await call('POST', '/api/jobs', { title: 'Render order 42', repo: REPO, requires_pr: false, ...fields }, token);
    expect(res.status).toBe(201);
    const { job } = await res.json();
    return allJobs().find(j => j.id === job.id);
  };
  const worker = (fields = {}) => ({
    id: 'w1', name: 'Viper', command: 'codex', agent: 'codex', state: 'WAITING', stateChangedAt: 0, isTUI: true,
    lastOutputAt: 0, recentStrippedLines: [], ownerId: null, exited: false, pty: { write: vi.fn() }, ...fields,
  });
  const working = (job, state = 'review') => {
    Object.assign(job, { state, agentSessionId: 'w1', agentName: 'Viper', branchName: 'board/x', resultSummary: 'done' });
    const w = worker();
    sessions.set('w1', w);
    return w;
  };

  beforeEach(() => {
    killed.length = 0;
    dropMessages('w1');
    sessions.set('session-2', { id: 'session-2', name: 'Mamba', repoPath: REPO, ownerId: null, exited: false, agentToken: OTHER_TOKEN });
  });

  it('records the poster on the card, from the credential that posted it', async () => {
    const job = await postCard();
    expect(job.postedById).toBe('agent:session-1');
    const userToken = withUser();
    expect((await postCard(userToken, { title: 'by a person' })).postedById).toBe('user:u1');
    noUsers();
    expect((await postCard(null, { title: 'anon' })).postedById).toBe('anonymous');
    // MCP post_job stamps the same key as the HTTP door.
    await callTool({ title: 'via mcp' });
    expect(allJobs().find(j => j.title === 'via mcp').postedById).toBe('agent:session-1');
  });

  it('lets the poster accept a Review card: Done, worker retired', async () => {
    const job = await postCard();
    working(job);
    const res = await call('POST', `/api/jobs/${job.id}/close`, { accept: true }, AGENT_TOKEN);
    expect(res.status).toBe(200);
    expect((await res.json()).accepted).toBe(true);
    expect(job.state).toBe('done');
    expect(killed).toEqual(['w1']);
  });

  it('lets the poster send a Review card back with its note', async () => {
    const job = await postCard();
    working(job);
    const res = await call('POST', `/api/jobs/${job.id}/close`, { accept: false, note: 'Shorter intro.' }, AGENT_TOKEN);
    expect(res.status).toBe(200);
    expect(job.state).toBe('todo');
    expect(job.detail).toContain('Sent back by Onyx: Shorter intro.');
  });

  it('archives a To do card on accept, and answers bad input with 400', async () => {
    const job = await postCard();
    expect((await call('POST', `/api/jobs/${job.id}/close`, {}, AGENT_TOKEN)).status).toBe(400);
    expect((await call('POST', `/api/jobs/${job.id}/close`, { accept: true }, AGENT_TOKEN)).status).toBe(400);   // no note
    const res = await call('POST', `/api/jobs/${job.id}/close`, { accept: true, note: 'Order cancelled.' }, AGENT_TOKEN);
    expect(res.status).toBe(200);
    expect((await res.json()).archived).toBe(true);
    expect(job.state).toBe('done');
    expect((await call('POST', '/api/jobs/nope/close', { accept: true }, AGENT_TOKEN)).status).toBe(404);
  });

  it('refuses every route to a session that did not post the card, with 403', async () => {
    const job = await postCard();
    const w = working(job);
    for (const [method, path, body] of [
      ['GET', `/api/jobs/${job.id}`],
      ['POST', `/api/jobs/${job.id}/close`, { accept: true }],
      ['POST', `/api/jobs/${job.id}/message`, { message: 'rm -rf' }],
    ]) {
      const res = await call(method, path, body, OTHER_TOKEN);
      expect(res.status, `${method} ${path}`).toBe(403);
    }
    // Nor a card the UI posted, from an anonymous caller on an auth-off board.
    job.postedById = null;
    expect((await call('POST', `/api/jobs/${job.id}/close`, { accept: true })).status).toBe(403);
    expect(job.state).toBe('review');
    expect(w.pty.write).not.toHaveBeenCalled();
  });

  it('keeps the poster across a restart: the id is on the persisted card', async () => {
    const job = await postCard(null);
    working(job);
    // As a restart reloads it: the stored card, a new object.
    config.jobs = JSON.parse(JSON.stringify(config.jobs));
    const reloaded = allJobs().find(j => j.id === job.id);
    expect(reloaded.postedById).toBe('anonymous');
    const res = await call('POST', `/api/jobs/${job.id}/close`, { accept: true });
    expect(res.status).toBe(200);
    expect(reloaded.state).toBe('done');
  });

  it('types a message into the card\'s worker, marked as from the poster', async () => {
    const job = await postCard();
    const w = working(job, 'in-progress');
    const res = await call('POST', `/api/jobs/${job.id}/message`, { message: 'Make the logo bigger.' }, AGENT_TOKEN);
    expect(res.status).toBe(202);
    const body = await res.json();
    expect(body.id).toMatch(/^msg-/);
    expect(body).not.toHaveProperty('to');
    const typed = () => w.pty.write.mock.calls.map(c => c[0]).join('');
    expect(typed()).toContain('[Message from Onyx, who posted your card]');
    await vi.waitFor(() => expect(typed()).toContain('> Make the logo bigger.'));
  });

  it('queues a message while the worker is busy, and caps its length', async () => {
    const job = await postCard();
    const w = working(job);
    w.state = 'WORKING';
    const res = await call('POST', `/api/jobs/${job.id}/message`, { message: 'Again, please.' }, AGENT_TOKEN);
    expect(res.status).toBe(202);
    expect((await res.json()).queued).toBe(1);
    expect(w.pty.write).not.toHaveBeenCalled();
    const long = await call('POST', `/api/jobs/${job.id}/message`, { message: 'x'.repeat(9000) }, AGENT_TOKEN);
    expect(long.status).toBe(400);
  });

  it('answers 409 with the state when the card has no live worker', async () => {
    const job = await postCard();
    let res = await call('POST', `/api/jobs/${job.id}/message`, { message: 'hi' }, AGENT_TOKEN);
    expect(res.status).toBe(409);
    expect(await res.json()).toEqual({ error: 'no live worker', state: 'todo' });
    working(job).exited = true;
    res = await call('POST', `/api/jobs/${job.id}/message`, { message: 'hi' }, AGENT_TOKEN);
    expect(res.status).toBe(409);
    expect((await res.json()).state).toBe('review');
  });

  it('reads the card back for its poster, with whether a worker is alive', async () => {
    const job = await postCard();
    let body = await (await call('GET', `/api/jobs/${job.id}`, null, AGENT_TOKEN)).json();
    expect(body.job.state).toBe('todo');
    expect(body.workerAlive).toBe(false);
    working(job);
    body = await (await call('GET', `/api/jobs/${job.id}`, null, AGENT_TOKEN)).json();
    expect(body.workerAlive).toBe(true);
    expect(body.job.resultSummary).toBe('done');
  });

  it('leaves Billion\'s rights as they were', async () => {
    const billion = { id: 'b1', name: 'Billion', isBillion: true };
    const job = await postCard();
    working(job);
    // Not its card: refused, even though Billion may close its own.
    expect((await closeJobForAgent({ session: billion, id: job.id, accept: true }, () => {})).forbidden).toBe(true);
    job.postedByBillion = true;
    expect((await closeJobForAgent({ session: billion, id: job.id, accept: true }, () => {}, { killSession: async () => {} })).accepted).toBe(true);
    // And the MCP door still refuses any other agent outright.
    expect((await closeJobForAgent({ session: { id: 'session-1' }, id: job.id, accept: true })).error).toMatch(/Only Billion/);
  });
});