Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
358 lines
15 KiB
JavaScript
358 lines
15 KiB
JavaScript
// @vitest-environment node
|
|
/**
|
|
* ADR-182 — Lean write-responses for agent MCP tools.
|
|
*
|
|
* Pins the two composable behaviors that fix the Friction-footer retrofit pain:
|
|
* T1 — update_table_row / batch_update_rows return a lean digest by default
|
|
* ({changed, bytes, tail}) instead of echoing the whole (multi-KB) row;
|
|
* full row is opt-in via response:"full", head preview via include_head.
|
|
* T2 — upsert_section idempotently replace-or-appends a marker-anchored section
|
|
* server-side, so the client no longer pulls the whole prompt, splices,
|
|
* pushes it back, and hand-verifies with SHA-256.
|
|
*
|
|
* Runs fully mocked (no DB) — parity with data-tools-automation-triggers.test.js.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
|
const dbGet = vi.fn();
|
|
const dbRun = vi.fn();
|
|
const dbAll = vi.fn();
|
|
|
|
vi.mock('../../../database/connection.js', () => ({
|
|
dbGet: (...a) => dbGet(...a),
|
|
dbRun: (...a) => dbRun(...a),
|
|
dbAll: (...a) => dbAll(...a),
|
|
isPostgres: () => true,
|
|
sqlNow: () => 'NOW()',
|
|
}));
|
|
|
|
vi.mock('../../../utils/baseId.js', () => ({ generateBaseId: () => 'BASEID01' }));
|
|
|
|
vi.mock('../../SelectValueResolver.js', () => ({
|
|
resolveSelectValues: vi.fn(async (_tableId, data) => ({ resolvedData: data, errors: [], rejections: [] })),
|
|
validateAllColumns: vi.fn(async () => ({ errors: [], rejections: [] })),
|
|
}));
|
|
|
|
vi.mock('../../atoms-archive.js', () => ({
|
|
applyAtomVersioning: vi.fn(async ({ newData }) => newData),
|
|
isAtomsV2Table: () => false,
|
|
}));
|
|
|
|
vi.mock('../coerceDataInput.js', () => ({ coerceDataObject: (data) => data }));
|
|
|
|
const fireRowCreateTriggers = vi.fn(() => Promise.resolve());
|
|
const fireRowUpdateTriggers = vi.fn(() => Promise.resolve());
|
|
vi.mock('../../AutomationTriggerService.js', () => ({
|
|
fireRowCreateTriggers: (...a) => fireRowCreateTriggers(...a),
|
|
fireRowUpdateTriggers: (...a) => fireRowUpdateTriggers(...a),
|
|
}));
|
|
|
|
const { dataToolHandlers, buildWriteDigest, upsertMarkedSection, pickFields } = await import('../data-tools.js');
|
|
|
|
beforeEach(() => {
|
|
dbGet.mockReset();
|
|
dbRun.mockReset();
|
|
dbAll.mockReset();
|
|
fireRowCreateTriggers.mockClear();
|
|
fireRowUpdateTriggers.mockClear();
|
|
});
|
|
|
|
// ── Pure helpers ────────────────────────────────────────────────────────────
|
|
|
|
describe('buildWriteDigest', () => {
|
|
it('reports changed[], per-field bytes and a ~120-char tail; no head by default', () => {
|
|
const long = 'x'.repeat(400);
|
|
const d = buildWriteDigest(['main_instructions'], { main_instructions: long, other: 'ignored' });
|
|
expect(d.changed).toEqual(['main_instructions']);
|
|
expect(d.bytes.main_instructions).toBe(400);
|
|
expect(d.tail.main_instructions.startsWith('…')).toBe(true);
|
|
expect(d.tail.main_instructions.length).toBe(121); // 120 chars + ellipsis
|
|
expect(d.other).toBeUndefined();
|
|
expect(d.head).toBeUndefined();
|
|
});
|
|
|
|
it('counts UTF-8 bytes, not code units', () => {
|
|
const d = buildWriteDigest(['f'], { f: '⚙️' }); // multi-byte
|
|
expect(d.bytes.f).toBe(Buffer.byteLength('⚙️', 'utf8'));
|
|
});
|
|
|
|
it('adds a head preview only when includeHead is set', () => {
|
|
const long = 'ab'.repeat(200);
|
|
const d = buildWriteDigest(['f'], { f: long }, { includeHead: true });
|
|
expect(d.head.f.endsWith('…')).toBe(true);
|
|
expect(d.head.f.length).toBe(121);
|
|
});
|
|
});
|
|
|
|
describe('upsertMarkedSection', () => {
|
|
const MARKER = '## ⚙️ Friction footer';
|
|
const SECTION = `${MARKER}\n- something rubbed → workaround`;
|
|
|
|
it('appends the section when the marker is absent', () => {
|
|
const out = upsertMarkedSection('# Prompt\n\nbody text', MARKER, SECTION);
|
|
expect(out).toBe('# Prompt\n\nbody text\n\n' + SECTION);
|
|
});
|
|
|
|
it('appends into an empty field without a leading separator', () => {
|
|
expect(upsertMarkedSection('', MARKER, SECTION)).toBe(SECTION);
|
|
});
|
|
|
|
it('replaces an existing section in place (up to the next same-level heading)', () => {
|
|
const src = `# Prompt\n\n${MARKER}\n- OLD content\n\n## Next heading\ntail`;
|
|
const out = upsertMarkedSection(src, MARKER, SECTION);
|
|
expect(out).toBe(`# Prompt\n\n${MARKER}\n- something rubbed → workaround\n\n## Next heading\ntail`);
|
|
expect(out).not.toContain('OLD content');
|
|
});
|
|
|
|
it('a deeper subheading inside the section is NOT a boundary', () => {
|
|
const src = `${MARKER}\n### sub\nkept-inside\n## sibling\nafter`;
|
|
const out = upsertMarkedSection(src, MARKER, SECTION);
|
|
expect(out).toContain('## sibling\nafter');
|
|
expect(out).not.toContain('kept-inside');
|
|
});
|
|
|
|
it('is idempotent: re-running with identical content is byte-identical', () => {
|
|
const base = '# Prompt\n\nbody';
|
|
const once = upsertMarkedSection(base, MARKER, SECTION);
|
|
const twice = upsertMarkedSection(once, MARKER, SECTION);
|
|
expect(twice).toBe(once); // this is what replaces Smith's manual SHA-256 check
|
|
});
|
|
});
|
|
|
|
// ── Handler: update_table_row ────────────────────────────────────────────────
|
|
|
|
describe('update_table_row — minimal-by-default echo (T1)', () => {
|
|
it('returns a lean digest by default, not the full row', async () => {
|
|
dbGet.mockResolvedValueOnce({ id: 1, table_id: 1784, data: { name: 'Bot', main_instructions: 'old' } });
|
|
dbRun.mockResolvedValueOnce({});
|
|
|
|
const res = await dataToolHandlers.update_table_row(
|
|
{ table_id: 1784, row_id: 1, data: { main_instructions: 'y'.repeat(300) } },
|
|
1
|
|
);
|
|
|
|
expect(res).toMatchObject({ success: true, table_id: 1784, row_id: 1, changed: ['main_instructions'] });
|
|
expect(res.bytes.main_instructions).toBe(300);
|
|
expect(res.tail.main_instructions.startsWith('…')).toBe(true);
|
|
expect(res.data).toBeUndefined(); // ← the whole point: no fat echo
|
|
expect(res.head).toBeUndefined();
|
|
});
|
|
|
|
it('returns the full row on response:"full" (RFC 7240 return=representation)', async () => {
|
|
dbGet.mockResolvedValueOnce({ id: 1, table_id: 1784, data: { name: 'Bot' } });
|
|
dbRun.mockResolvedValueOnce({});
|
|
|
|
const res = await dataToolHandlers.update_table_row(
|
|
{ table_id: 1784, row_id: 1, data: { name: 'Bot2' }, response: 'full' },
|
|
1
|
|
);
|
|
|
|
expect(res.data).toMatchObject({ name: 'Bot2' });
|
|
expect(res.changed).toBeUndefined();
|
|
});
|
|
|
|
it('adds a head preview on include_head:true', async () => {
|
|
dbGet.mockResolvedValueOnce({ id: 1, table_id: 1784, data: {} });
|
|
dbRun.mockResolvedValueOnce({});
|
|
|
|
const res = await dataToolHandlers.update_table_row(
|
|
{ table_id: 1784, row_id: 1, data: { f: 'z'.repeat(300) }, include_head: true },
|
|
1
|
|
);
|
|
|
|
expect(res.head.f.endsWith('…')).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── Handler: batch_update_rows ───────────────────────────────────────────────
|
|
|
|
describe('batch_update_rows — opt-in response (T1b)', () => {
|
|
it('default stays lean: success is an array of row ids', async () => {
|
|
dbGet
|
|
.mockResolvedValueOnce({ id: 1784 }) // table exists
|
|
.mockResolvedValueOnce({ data: { s: 'a' } }) // row 11
|
|
.mockResolvedValueOnce({ data: { s: 'a' } }); // row 22
|
|
dbRun.mockResolvedValue({});
|
|
|
|
const res = await dataToolHandlers.batch_update_rows(
|
|
{ table_id: 1784, updates: [{ row_id: 11, data: { s: 'b' } }, { row_id: 22, data: { s: 'b' } }] },
|
|
1
|
|
);
|
|
|
|
expect(res.success).toEqual([11, 22]);
|
|
});
|
|
|
|
it('response:"full" carries per-row merged data', async () => {
|
|
dbGet
|
|
.mockResolvedValueOnce({ id: 1784 })
|
|
.mockResolvedValueOnce({ data: { s: 'a' } });
|
|
dbRun.mockResolvedValue({});
|
|
|
|
const res = await dataToolHandlers.batch_update_rows(
|
|
{ table_id: 1784, updates: [{ row_id: 11, data: { s: 'b' } }], response: 'full' },
|
|
1
|
|
);
|
|
|
|
expect(res.success).toEqual([{ row_id: 11, data: { s: 'b' } }]);
|
|
});
|
|
});
|
|
|
|
// ── Handler: upsert_section ──────────────────────────────────────────────────
|
|
|
|
describe('upsert_section (T2)', () => {
|
|
const MARKER = '## ⚙️ Friction footer';
|
|
const SECTION = `${MARKER}\n- rubbed → workaround`;
|
|
|
|
it('appends the section, fires the automation, and returns a digest (never the whole field)', async () => {
|
|
dbGet.mockResolvedValueOnce({ id: 5, table_id: 1784, data: { main_instructions: '# Prompt\n\nbody' } });
|
|
dbRun.mockResolvedValueOnce({});
|
|
|
|
const res = await dataToolHandlers.upsert_section(
|
|
{ table_id: 1784, row_id: 5, field: 'main_instructions', marker: MARKER, content: SECTION },
|
|
1
|
|
);
|
|
|
|
expect(res).toMatchObject({ success: true, table_id: 1784, row_id: 5, changed: ['main_instructions'] });
|
|
expect(res.data).toBeUndefined(); // digest, not the whole field
|
|
expect(res.tail.main_instructions).toContain('workaround');
|
|
// what was actually persisted:
|
|
const stored = JSON.parse(dbRun.mock.calls[0][1][0]);
|
|
expect(stored.main_instructions).toBe('# Prompt\n\nbody\n\n' + SECTION);
|
|
expect(fireRowUpdateTriggers).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('replaces an existing section in place', async () => {
|
|
const start = `# Prompt\n\n${MARKER}\n- OLD`;
|
|
dbGet.mockResolvedValueOnce({ id: 5, table_id: 1784, data: { main_instructions: start } });
|
|
dbRun.mockResolvedValueOnce({});
|
|
|
|
await dataToolHandlers.upsert_section(
|
|
{ table_id: 1784, row_id: 5, field: 'main_instructions', marker: MARKER, content: SECTION },
|
|
1
|
|
);
|
|
|
|
const stored = JSON.parse(dbRun.mock.calls[0][1][0]);
|
|
expect(stored.main_instructions).toBe(`# Prompt\n\n${MARKER}\n- rubbed → workaround`);
|
|
expect(stored.main_instructions).not.toContain('OLD');
|
|
});
|
|
|
|
it('is idempotent end-to-end: second run persists byte-identical field state', async () => {
|
|
// Run 1 — marker absent → append.
|
|
dbGet.mockResolvedValueOnce({ id: 5, table_id: 1784, data: { main_instructions: '# Prompt\n\nbody' } });
|
|
dbRun.mockResolvedValueOnce({});
|
|
await dataToolHandlers.upsert_section(
|
|
{ table_id: 1784, row_id: 5, field: 'main_instructions', marker: MARKER, content: SECTION }, 1
|
|
);
|
|
const afterFirst = JSON.parse(dbRun.mock.calls[0][1][0]).main_instructions;
|
|
|
|
// Run 2 — feed the stored value back → replace → must equal run 1.
|
|
dbGet.mockResolvedValueOnce({ id: 5, table_id: 1784, data: { main_instructions: afterFirst } });
|
|
dbRun.mockResolvedValueOnce({});
|
|
await dataToolHandlers.upsert_section(
|
|
{ table_id: 1784, row_id: 5, field: 'main_instructions', marker: MARKER, content: SECTION }, 1
|
|
);
|
|
const afterSecond = JSON.parse(dbRun.mock.calls[1][1][0]).main_instructions;
|
|
|
|
expect(afterSecond).toBe(afterFirst);
|
|
});
|
|
|
|
it('rejects a missing row and does not write', async () => {
|
|
dbGet.mockResolvedValueOnce(undefined);
|
|
const res = await dataToolHandlers.upsert_section(
|
|
{ table_id: 1784, row_id: 999, field: 'main_instructions', marker: MARKER, content: SECTION }, 1
|
|
);
|
|
expect(res.error).toBeTruthy();
|
|
expect(dbRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('validates required string args', async () => {
|
|
const res = await dataToolHandlers.upsert_section(
|
|
{ table_id: 1784, row_id: 5, field: 'main_instructions', marker: MARKER }, 1
|
|
);
|
|
expect(res.error).toMatch(/content is required/);
|
|
});
|
|
});
|
|
|
|
// ─── Tier 3 — read-side field projection ────────────────────────────────────
|
|
|
|
describe('pickFields (T3 helper)', () => {
|
|
it('keeps only requested keys and reports unknowns (lenient, never throws)', () => {
|
|
const { picked, unknown } = pickFields(
|
|
{ name: 'Bot', role: 'dev', main_instructions: 'x' },
|
|
['name', 'nope', 'role']
|
|
);
|
|
expect(picked).toEqual({ name: 'Bot', role: 'dev' });
|
|
expect(unknown).toEqual(['nope']);
|
|
});
|
|
|
|
it('tolerates a null/undefined data object', () => {
|
|
expect(pickFields(null, ['a'])).toEqual({ picked: {}, unknown: ['a'] });
|
|
});
|
|
});
|
|
|
|
describe('query_table_data — field projection (T3, flat container)', () => {
|
|
const rows = [
|
|
{ id: 11, data: { name: 'Bot', role: 'dev', main_instructions: 'long…' }, created_at: 'T1' },
|
|
{ id: 12, data: { name: 'Ann', role: 'qa', main_instructions: 'also long…' }, created_at: 'T2' },
|
|
];
|
|
|
|
it('omitted fields → full row, no unknown_fields key (byte-identical default)', async () => {
|
|
dbGet.mockResolvedValueOnce({ id: 1784, name: 'Agents' });
|
|
dbAll.mockResolvedValueOnce(rows);
|
|
const res = await dataToolHandlers.query_table_data({ table_id: 1784 });
|
|
expect(res.rows[0]).toEqual({ id: 11, name: 'Bot', role: 'dev', main_instructions: 'long…', created_at: 'T1' });
|
|
expect(res).not.toHaveProperty('unknown_fields');
|
|
});
|
|
|
|
it('fields → projects data columns; id + created_at always present', async () => {
|
|
dbGet.mockResolvedValueOnce({ id: 1784, name: 'Agents' });
|
|
dbAll.mockResolvedValueOnce(rows);
|
|
const res = await dataToolHandlers.query_table_data({ table_id: 1784, fields: ['name'] });
|
|
expect(res.rows[0]).toEqual({ id: 11, name: 'Bot', created_at: 'T1' });
|
|
expect(res.rows[1]).toEqual({ id: 12, name: 'Ann', created_at: 'T2' });
|
|
expect(res.rows[0]).not.toHaveProperty('main_instructions');
|
|
expect(res.unknown_fields).toEqual([]);
|
|
});
|
|
|
|
it('unknown field names are ignored and deduped into unknown_fields[]', async () => {
|
|
dbGet.mockResolvedValueOnce({ id: 1784, name: 'Agents' });
|
|
dbAll.mockResolvedValueOnce(rows);
|
|
const res = await dataToolHandlers.query_table_data({ table_id: 1784, fields: ['name', 'ghost'] });
|
|
expect(res.rows[0]).toEqual({ id: 11, name: 'Bot', created_at: 'T1' });
|
|
expect(res.unknown_fields).toEqual(['ghost']); // one entry despite two rows
|
|
});
|
|
});
|
|
|
|
describe('get_table_row — field projection (T3, nested row.data container)', () => {
|
|
const table = { id: 1784, name: 'Agents' };
|
|
const row = {
|
|
id: 11, base_id: 'B1', table_id: 1784,
|
|
data: { name: 'Bot', role: 'dev', main_instructions: 'long…' },
|
|
created_by: 1, created_at: 'T1', updated_at: 'T2',
|
|
};
|
|
const columns = [{ id: 1, column_name: 'name', display_name: 'Name', type: 'text' }];
|
|
|
|
it('omitted fields → full row.data, no unknown_fields key', async () => {
|
|
dbGet.mockResolvedValueOnce(table).mockResolvedValueOnce(row);
|
|
dbAll.mockResolvedValueOnce(columns);
|
|
const res = await dataToolHandlers.get_table_row({ table_id: 1784, row_id: 11 });
|
|
expect(res.row.data).toEqual({ name: 'Bot', role: 'dev', main_instructions: 'long…' });
|
|
expect(res).not.toHaveProperty('unknown_fields');
|
|
});
|
|
|
|
it('fields → projects row.data; structural siblings always kept', async () => {
|
|
dbGet.mockResolvedValueOnce(table).mockResolvedValueOnce(row);
|
|
dbAll.mockResolvedValueOnce(columns);
|
|
const res = await dataToolHandlers.get_table_row({ table_id: 1784, row_id: 11, fields: ['name', 'missing'] });
|
|
expect(res.row.data).toEqual({ name: 'Bot' }); // only the projected column
|
|
// structural keys survive regardless of the allow-list
|
|
expect(res.row.id).toBe(11);
|
|
expect(res.row.base_id).toBe('B1');
|
|
expect(res.row.table_id).toBe(1784);
|
|
expect(res.row.created_by).toBe(1);
|
|
expect(res.row.created_at).toBe('T1');
|
|
expect(res.row.updated_at).toBe('T2');
|
|
expect(res.unknown_fields).toEqual(['missing']);
|
|
});
|
|
});
|