agenthub/tests/askService.test.ts
chahinebrini f0c7be25f4 feat(agenthub): TSK-0118 — Ask primitive for autonomous decision-routing
- New Ask entity (ASK-####): schema + counter + paths(EntityType 'asks') +
  events(AgentHubEventType 'ask') + fsWatch(WATCHED + toEvent). Generic entities
  table, no migration.
- askService: createAsk routes to config.roles.architect.preferredAgent, NEVER
  the CEO (a to='ceo' is rerouted); answerAsk / escalateAsk (escalatedTo='ceo',
  single channel) / getAsk / listAsks. Authority-policy JSDoc.
- Asks kept OUT of FTS5: Index.upsert gains a { fts?: boolean } option; askService
  upserts with fts:false, so 'memory search' never returns asks.
- routes: POST/GET /asks, GET /asks/:id, POST /asks/:id/{answer,escalate}, each
  emitChange type:'ask'.
- CLI 'ask <q> --from [--task][--wait][--timeout]' (SSE reconnect wait until
  status!=pending) + ask list/answer/escalate; remoteClient ask methods.
- MCP agenthub_ask (wait via waitForTask, now woken by 'ask' events) +
  agenthub_ask_list/answer/escalate; agenthub_work architect branch surfaces
  pending asks ({reviews,asks,messages}).
- Unattended mode (invocation flag): work.ts ctx + CLI 'work --unattended' +
  agenthub_work schema + LOOP reminder ('call agenthub_ask instead of pausing').
- tests: +askService.test.ts (routing/answer/escalate/list/FTS-exclusion),
  +ask-wait.test.ts (routes roundtrip + SSE wait: answer resolves, no-answer
  times out cleanly)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 01:19:42 +02:00

81 lines
3.4 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { init } from '../src/cli/commands/init.js';
import { createAsk, answerAsk, escalateAsk, listAsks, getAsk } from '../src/core/services/askService.js';
import { askCreate, askAnswer, askEscalate } from '../src/cli/commands/ask.js';
import { addMemory, searchMemory } from '../src/core/services/memoryService.js';
describe('askService (TSK-0118)', () => {
let cwd: string;
beforeEach(async () => {
cwd = mkdtempSync(join(tmpdir(), 'ah-ask-'));
await init(cwd, { yes: true, projectName: 'test' });
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('routes to the architect (never the CEO) by default', () => {
const ask = createAsk(cwd, { from: 'kimi', question: 'Which DB driver?' });
expect(ask.id).toMatch(/^ASK-\d{4}$/);
expect(ask.to).toBe('claude'); // config.roles.architect.preferredAgent
expect(ask.status).toBe('pending');
});
it('refuses to route to the CEO — reroutes to the architect', () => {
const ask = createAsk(cwd, { from: 'kimi', to: 'ceo', question: 'ship it?' });
expect(ask.to).toBe('claude');
});
it('answerAsk closes it as answered with the answer + author', () => {
const ask = createAsk(cwd, { from: 'kimi', question: 'Which DB driver?' });
const answered = answerAsk(cwd, ask.id, 'node:sqlite', 'claude');
expect(answered.status).toBe('answered');
expect(answered.answer).toBe('node:sqlite');
expect(answered.answeredBy).toBe('claude');
expect(getAsk(cwd, ask.id).ask.status).toBe('answered');
});
it('escalateAsk flips to escalated → ceo (single channel, no second ask)', () => {
const ask = createAsk(cwd, { from: 'kimi', question: 'Publish to OSS?' });
const escalated = escalateAsk(cwd, ask.id, 'OSS decision — CEO call');
expect(escalated.status).toBe('escalated');
expect(escalated.escalatedTo).toBe('ceo');
});
it('listAsks filters by status and recipient', () => {
const a1 = createAsk(cwd, { from: 'kimi', question: 'q1' });
createAsk(cwd, { from: 'codex', question: 'q2' });
answerAsk(cwd, a1.id, 'yes');
expect(listAsks(cwd)).toHaveLength(2);
expect(listAsks(cwd, { status: 'pending' })).toHaveLength(1);
expect(listAsks(cwd, { to: 'claude' })).toHaveLength(2);
expect(listAsks(cwd, { to: 'nobody' })).toHaveLength(0);
});
it('asks are NOT indexed in FTS5 (memory search never returns them)', () => {
createAsk(cwd, { from: 'kimi', question: 'znamqvist widget architecture' });
addMemory(cwd, { title: 'note', content: 'znamqvist widget architecture' });
const hits = searchMemory(cwd, 'znamqvist');
expect(hits.some((r) => r.type === 'ask')).toBe(false); // ask excluded from FTS
expect(hits.some((r) => r.type === 'memory')).toBe(true); // search still works
});
it('ask-cmd helpers create/answer/escalate through the service', () => {
const ask = askCreate(cwd, { from: 'kimi', question: 'via cmd?' });
expect(ask.status).toBe('pending');
askAnswer(cwd, ask.id, 'ok', 'claude');
expect(getAsk(cwd, ask.id).ask.status).toBe('answered');
const ask2 = askCreate(cwd, { from: 'kimi', question: 'escalate me' });
askEscalate(cwd, ask2.id, 'needs ceo');
expect(getAsk(cwd, ask2.id).ask.status).toBe('escalated');
});
});