- 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>
81 lines
3.4 KiB
TypeScript
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');
|
|
});
|
|
});
|