Ctrl K
ComunidadGuías…

tests/decision-intake.test.ts

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

Archivo original ↗Preguntar a KiBot
import assert from 'node:assert/strict';
import { writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import test from 'node:test';
import { FileDecisionStore } from '../src/adapters/storage/decision-store.js';
import { CandidateDecisionEvaluator } from '../src/application/decisions.js';
import { approveCandidate, evaluateCandidate, processIssueCandidates, refreshCandidates, reproduceCandidate } from '../src/application/candidates.js';
import { processServiceBatch } from '../src/application/service.js';
import { main } from '../src/cli/bootstrap.js';
import { retryDecision } from '../src/cli/commands/decisions.js';
import { decisionSchema, type DecisionConfig } from '../src/domain/decision.js';
import { pollEvent } from '../src/domain/service-events.js';
import { DecisionModelError, type DecisionModel } from '../src/ports/decision.js';
import { decisionResult } from './decision-helpers.js';
import { serviceFixture } from './service-helpers.js';

type Fixture = Awaited<ReturnType<typeof serviceFixture>>;
function enable(d: Fixture['d'], raw: Partial<DecisionConfig>, model: DecisionModel) {
  d.config.decision = decisionSchema.parse({ enabled: true, ...raw });
  const store = new FileDecisionStore(d.config.dataDir);
  d.decision = new CandidateDecisionEvaluator(d.config.repository, d.config.decision, store, model);
  return store;
}

test('disabled decision configuration never calls Jev or changes existing candidate execution', async () => {
  const { d, modelCalls } = await serviceFixture();
  d.decision = { evaluate: async () => { assert.fail('disabled Jev must not run'); } };
  const result = await processIssueCandidates(d);
  assert.equal(result.processed[0]?.status, 'reproduced'); assert.ok(modelCalls() > 0);
});

test('shadow mode records unfavorable Jev advice while preserving stable reproduction and manual approval', async () => {
  const { d, modelCalls } = await serviceFixture();
  const store = enable(d, { mode: 'shadow' }, { evaluate: async () => decisionResult({ risk: 'high' }) });
  const result = await processIssueCandidates(d);
  assert.equal(result.processed[0]?.status, 'reproduced'); assert.ok(modelCalls() > 0);
  const candidate = (await d.candidates.list())[0]!;
  assert.equal(candidate.decision?.route, 'review'); assert.equal((await store.list(d.config.repository)).length, 1);
  assert.equal(result.pendingApproval, 1); assert.deepEqual(await d.goals.list(), []);
});

test('gate mode defers unsafe requests without Codex usage, candidate failure retries or a service circuit failure', async () => {
  const { d, modelCalls } = await serviceFixture({ autoApprove: true });
  enable(d, { mode: 'gate' }, { evaluate: async () => decisionResult({ risk: 'high' }) });
  await d.services.enqueue(pollEvent(d.config.repository, 'decision-gate', 'issue', 7), 1000);
  const result = await processServiceBatch(d, () => {}, error => assert.fail(error));
  const candidate = (await d.candidates.list())[0]!;
  assert.equal(candidate.status, 'observed'); assert.equal(candidate.failureCount, undefined);
  assert.equal(modelCalls(), 0); assert.equal(result.failed, 0); assert.equal((await d.budget.status()).used.tasks, 0);
  assert.equal((await d.budget.status()).failures, 0); assert.deepEqual(await d.goals.list(), []);
});

test('low confidence, vague criteria and a different inferred request type require review instead of changing the trusted intent', async () => {
  const uncertain = decisionResult(); uncertain.answers.risk.confidence = 0.2;
  for (const response of [uncertain, decisionResult({ testable: 0.5 }), decisionResult({ kind: 'feature' })]) {
    const { d, modelCalls } = await serviceFixture({ autoApprove: true });
    enable(d, { mode: 'gate' }, { evaluate: async () => response });
    const result = await processIssueCandidates(d);
    assert.equal(result.deferredByDecision.length, 1); assert.equal(modelCalls(), 0);
    assert.deepEqual(await d.goals.list(), []);
  }
});

test('favorable Jev decisions cannot replace reproduction evidence or grant automatic approval', async () => {
  const { d } = await serviceFixture();
  enable(d, { mode: 'gate' }, { evaluate: async () => decisionResult() });
  await processIssueCandidates(d);
  assert.equal((await d.candidates.list())[0]?.status, 'reproduced'); assert.deepEqual(await d.goals.list(), []);
});

test('revoked authorization is checked before sending Issue content, and changes during evaluation invalidate the result', async () => {
  const { d, issue, modelCalls } = await serviceFixture(); let calls = 0;
  enable(d, { mode: 'gate' }, { evaluate: async () => { calls++; issue.body = 'Changed request while Jev was in progress.'; return decisionResult(); } });
  const candidate = (await refreshCandidates(d, 'issues')).candidates[0]!;
  const labels = issue.labels; issue.labels = [];
  assert.equal((await evaluateCandidate(candidate.id, d)).candidate.status, 'stale'); assert.equal(calls, 0);
  issue.labels = labels; await refreshCandidates(d, 'issues');
  const result = await evaluateCandidate(candidate.id, d);
  assert.equal(result.candidate.status, 'stale'); assert.equal(result.candidate.decision, undefined);
  assert.equal(calls, 1); assert.equal(modelCalls(), 0);
});

test('an explicitly reproduced and approved Issue can recover without a decision granting or revoking that approval', async () => {
  const { d } = await serviceFixture();
  enable(d, { mode: 'gate' }, { evaluate: async () => decisionResult({ risk: 'high' }) });
  await processIssueCandidates(d);
  const candidate = (await d.candidates.list())[0]!;
  await reproduceCandidate(candidate.id, d); await approveCandidate(candidate.id, candidate.evidenceDigest, d);
  d.decision = { evaluate: async () => { assert.fail('approved recovery must not request another admission decision'); } };
  const result = await processIssueCandidates(d);
  assert.equal(result.processed[0]?.status, 'completed'); assert.equal((await d.goals.list()).length, 1);
});

test('a cached review decision cannot starve later eligible candidates under maxPerRun', async () => {
  const { d, issue } = await serviceFixture(); let decisions = 0;
  d.github.issues = async () => [issue, { ...issue, number: 8 }];
  d.github.issue = async number => ({ ...issue, number });
  enable(d, { mode: 'gate' }, { evaluate: async subject => { decisions++; return decisionResult({ risk: subject.issue === 7 ? 'high' : 'low' }); } });
  await processIssueCandidates(d); assert.equal(decisions, 1);
  await processIssueCandidates(d); assert.equal(decisions, 2);
  const candidates = await d.candidates.list();
  assert.equal(candidates.find(candidate => candidate.issue === 7)!.status, 'observed');
  assert.equal(candidates.find(candidate => candidate.issue === 8)!.status, 'reproduced');
});

test('missing Jev credentials defer in gate mode and errors are cached until explicitly retried within existing budgets', async () => {
  const { d, modelCalls } = await serviceFixture(); let requests = 0;
  const store = enable(d, { mode: 'gate' }, { evaluate: async () => { requests++; throw new DecisionModelError('missing_key', false); } });
  await processIssueCandidates(d); await processIssueCandidates(d); assert.equal(requests, 1); assert.equal(modelCalls(), 0);
  const record = (await store.list(d.config.repository))[0]!, output = { write: (_line: string) => {}, error: (_line: string) => {} };
  await assert.rejects(retryDecision(record.id, 'a'.repeat(24), d.config, store, output), /does not match/);
  await retryDecision(record.id, record.inputDigest, d.config, store, output);
  await processIssueCandidates(d); assert.equal(requests, 2);
  assert.equal((await store.list(d.config.repository))[0]!.attempts, 2);
  await retryDecision(record.id, record.inputDigest, d.config, store, output);
  await processIssueCandidates(d); assert.equal(requests, 3);
  await assert.rejects(retryDecision(record.id, record.inputDigest, d.config, store, output), /original attempt allowance/);
});

test('service operator pause reaches active Jev evaluation and retains recoverable unknown usage', async () => {
  const { d, modelCalls } = await serviceFixture();
  const store = enable(d, { mode: 'gate' }, { evaluate: async (_subject, signal) => {
    await d.services.requestControl(d.config.repository, 'pause');
    await new Promise<never>((_resolve, reject) => {
      if (signal!.aborted) reject(signal!.reason);
      else signal!.addEventListener('abort', () => reject(signal!.reason), { once: true });
    });
    return decisionResult();
  } });
  await d.services.enqueue(pollEvent(d.config.repository, 'pause-decision', 'issue', 7), 1000);
  await processServiceBatch(d, () => {}, error => assert.fail(error));
  assert.equal((await d.budget.status()).paused, true); assert.equal(modelCalls(), 0);
  assert.equal((await store.list(d.config.repository))[0]!.failure, 'interrupted');
  assert.equal(Object.keys((await store.ledger(d.config.repository))!.reservations).length, 1);
  assert.equal((await d.services.events(d.config.repository))[0]!.receipt!.attempts, 0);
});

test('decision inspection works under the controller lock, validates IDs and enforces repository isolation', async () => {
  const { d, dataDir } = await serviceFixture();
  const store = enable(d, { mode: 'gate' }, { evaluate: async () => decisionResult() });
  const candidate = (await refreshCandidates(d, 'issues')).candidates[0]!; await evaluateCandidate(candidate.id, d);
  const record = (await store.list(d.config.repository))[0]!, configPath = resolve(dataDir, 'config.json');
  await writeFile(configPath, JSON.stringify(d.config));
  const lines: string[] = [], output = { write: (line: string) => lines.push(line), error: (_line: string) => {} };
  const release = await d.store.acquire();
  try {
    for (const args of [['status'], ['list'], ['show', record.id]])
      assert.equal(await main(['decisions', ...args, '--config', configPath], output), 0);
    assert.ok(lines.join('\n').includes('jev-1.13.0'));
    await assert.rejects(main(['decisions', 'show', '../private', '--config', configPath], output), /decision ID/);
    await writeFile(configPath, JSON.stringify({ ...d.config, repository: 'other/repo' }));
    await assert.rejects(main(['decisions', 'show', record.id, '--config', configPath], output), /not found/);
  } finally { await release(); }
});