SPB Git

Repository Public

criteria.test.ts 7.5 KB 129 lines typescript

1/** Missions created without done criteria: propose → owner confirmation → writes allowed (CONTRACT §5.1). Question waits. */2import { resolveWait } from '@aos/kernel';3import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';4import { readProposalAnswer } from '../src/index.js';5import { scriptedProvider, toolResultsOf, userTextsOf, type ScriptItem } from '../src/testkit/index.js';6import {7  cancelQuietly, createTestDb, criteriaOf, events, exists, makeWorkspace, newMission, pendingWait, read, startEngine, toolSteps, waitStatus,8  type MissionRef, type TestDb, type TestWorker,9} from './helpers.js';1011let db: TestDb;12let worker: TestWorker | null = null;13let mission: MissionRef | null = null;14beforeAll(async () => {15  db = await createTestDb();16});17afterEach(async () => {18  await worker?.stop();19  worker = null;20  if (mission) await cancelQuietly(db, mission.missionId);21  mission = null;22});23afterAll(async () => {24  await db.cleanup();25});26async function start(script: ScriptItem[], over: Record<string, unknown> = {}) {27  const ws = makeWorkspace(db, { files: { 'a.txt': 'a\n' } });28  const provider = scriptedProvider(script);29  mission = await newMission(db, ws, over);30  worker = startEngine(db, provider);31  return { provider, m: mission, ws };32}3334const WRITE = { name: 'write_file', input: { path: 'out.txt', content: 'x\n' } };35const SHELL = { name: 'shell', input: { command: 'touch touched', timeoutSec: null } };36const PROPOSE = (criteria: string[]) => ({ name: 'propose_criteria', input: { criteria, rationale: 'covers the objective' } });37const DONE = { name: 'finish', input: { status: 'done', summary: 'Done.', reason: null, evidence: [], openQuestions: [] } };3839describe('criteria proposal protocol', () => {40  it('refuses writes until the owner confirms; a reply with changes leads to a new proposal', async () => {41    const { provider, m, ws } = await start(42      [43        { calls: [WRITE, SHELL, { name: 'read_file', input: { path: 'a.txt', offset: null, limit: null } }] },44        { calls: [PROPOSE(['out.txt exists', 'tests pass'])] },45        { calls: [WRITE] }, // still refused: the proposal was not accepted46        { calls: [PROPOSE(['out.txt exists'])] },47        { calls: [WRITE] },48        { calls: [{ name: 'update_criteria', input: { updates: [{ id: 'c1', passes: true, evidence: 'ls shows out.txt' }] } }, DONE] },49      ],50      { doneCriteria: [] },51    );52    const w1 = await pendingWait(db, m.runId);53    expect(w1.kind).toBe('question');54    expect(w1.payload).toMatchObject({ source: 'propose_criteria', proposed: ['out.txt exists', 'tests pass'], options: ['Yes — use these criteria'] });55    expect(String(w1.payload.detail)).toContain('1. out.txt exists\n2. tests pass');56    expect(exists(ws, 'out.txt')).toBe(false);57    expect(exists(ws, 'touched')).toBe(false);58    expect(await criteriaOf(db, m.missionId)).toEqual([]);59    // the first prompt told the agent to propose criteria, and propose_criteria is offered60    expect(userTextsOf(provider.requests[0]?.transcript ?? [])[0]).toMatch(/None were given.*propose_criteria/s);61    expect(provider.requests[0]?.toolNames).toContain('propose_criteria');6263    await resolveWait(w1.id, { answer: 'There are no tests here — drop the second one.' }, 'owner', db.k);64    const w2 = await pendingWait(db, m.runId);65    expect(w2.id).not.toBe(w1.id);66    expect(w2.payload).toMatchObject({ proposed: ['out.txt exists'] });67    expect(exists(ws, 'out.txt')).toBe(false);68    await resolveWait(w2.id, { answer: 'Yes — use these criteria' }, 'owner', db.k);6970    const run = await waitStatus(db, m.runId);71    expect(run.status).toBe('done');72    expect(read(ws, 'out.txt')).toBe('x\n');73    expect(await criteriaOf(db, m.missionId)).toEqual([{ id: 'c1', text: 'out.txt exists', passes: true, evidence: 'ls shows out.txt' }]);74    const seen = toolResultsOf(provider.lastTranscript());75    expect(seen.slice(0, 3).map((r) => r.ok)).toEqual([false, false, true]);76    expect(seen[0]?.content).toMatch(/done criteria are not confirmed yet/);77    expect(seen[3]?.content).toMatch(/did not accept the criteria.*drop the second one/s);78    expect(seen[4]).toMatchObject({ name: 'write_file', ok: false });79    expect(seen[5]?.content).toMatch(/owner confirmed the done criteria:\nc1\. out\.txt exists/);80    expect(seen[6]).toMatchObject({ name: 'write_file', ok: true });81    expect((await toolSteps(db, m.runId)).map((s) => s.name)).toEqual(['read_file', 'propose_criteria', 'write_file', 'update_criteria', 'finish']);82    const notices = (await events(db, m.missionId, 'system.notice')).filter((e) => e.code === 'criteria');83    expect(notices).toHaveLength(2);84    expect(notices[0]?.doneCriteria).toEqual([{ id: 'c1', text: 'out.txt exists', passes: false }]);85  });8687  it('"done" is impossible without confirmed criteria, and a second proposal is refused once confirmed', async () => {88    const { provider, m } = await start(89      [{ calls: [DONE] }, { calls: [PROPOSE(['a.txt is unchanged'])] }, { calls: [PROPOSE(['something else'])] }, { calls: [{ name: 'finish', input: { status: 'partial', summary: 'Stopped.', reason: 'test', evidence: [], openQuestions: [] } }] }],90      { doneCriteria: [] },91    );92    const w = await pendingWait(db, m.runId);93    await resolveWait(w.id, { answer: 'yes, and keep it short' }, 'owner', db.k);94    expect((await waitStatus(db, m.runId)).status).toBe('partial');95    const seen = toolResultsOf(provider.lastTranscript());96    expect(seen[0]?.content).toMatch(/no confirmed done criteria/);97    expect(seen[1]?.content).toMatch(/Owner's note: yes, and keep it short/);98    expect(seen[2]).toMatchObject({ name: 'propose_criteria', ok: false });99    expect(seen[2]?.content).toMatch(/already confirmed/);100  });101102  it('reads proposal answers', () => {103    expect(readProposalAnswer('Yes — use these criteria')).toEqual({ accepted: true, note: null });104    expect(readProposalAnswer(' OK. ')).toEqual({ accepted: true, note: null });105    expect(readProposalAnswer('Yes, but do not push the branch')).toEqual({ accepted: true, note: 'Yes, but do not push the branch' });106    expect(readProposalAnswer('yesterday it failed')).toEqual({ accepted: false, note: 'yesterday it failed' });107    expect(readProposalAnswer('Add a criterion about docs')).toMatchObject({ accepted: false });108    expect(readProposalAnswer('')).toEqual({ accepted: false, note: null });109  });110});111112describe('ask_owner', () => {113  it('parks the run on a durable question and resumes with the answer as the tool result', async () => {114    const { provider, m } = await start([115      { calls: [{ name: 'ask_owner', input: { question: 'Which database should I target?', options: ['Postgres', 'SQLite'] } }] },116      { calls: [{ name: 'finish', input: { status: 'partial', summary: 'Got the answer.', reason: 'test', evidence: [], openQuestions: [] } }] },117    ]);118    const w = await pendingWait(db, m.runId);119    expect(w).toMatchObject({ kind: 'question', payload: { text: 'Which database should I target?', options: ['Postgres', 'SQLite'], source: 'ask_owner' } });120    expect((await waitStatus(db, m.runId, ['waiting'])).status).toBe('waiting');121    // the worker restarts while the question is pending: the wait is durable122    await worker?.stop();123    worker = startEngine(db, provider);124    await resolveWait(w.id, { answer: 'Postgres' }, 'owner', db.k);125    expect((await waitStatus(db, m.runId)).status).toBe('partial');126    expect(toolResultsOf(provider.lastTranscript())[0]).toEqual({ callId: 'call_0_0', name: 'ask_owner', ok: true, content: 'The owner answered:\nPostgres' });127  });128});129
Files main
Menu

Theme