Repository Public
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