- 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>
94 lines
3.7 KiB
TypeScript
94 lines
3.7 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 { buildApp } from '../src/server/index.js';
|
|
import { startServer } from '../src/server/index.js';
|
|
import { waitForAsk } from '../src/cli/commands/ask.js';
|
|
import type { Ask } from '../src/core/schema.js';
|
|
|
|
describe('ask routes (TSK-0118)', () => {
|
|
let cwd: string;
|
|
let app: ReturnType<typeof buildApp>;
|
|
|
|
beforeEach(() => {
|
|
cwd = mkdtempSync(join(tmpdir(), 'ah-ask-routes-'));
|
|
init(cwd, { projectName: 'ask-routes', yes: true });
|
|
app = buildApp(cwd);
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
});
|
|
|
|
it('POST/GET/answer/escalate roundtrip', async () => {
|
|
const created = JSON.parse((await app.inject({ method: 'POST', url: '/asks', payload: { from: 'kimi', question: 'q?' } })).payload) as Ask;
|
|
expect(created.id).toBe('ASK-0001');
|
|
expect(created.to).toBe('claude');
|
|
expect(created.status).toBe('pending');
|
|
|
|
// GET /asks — the data source agenthub_work's architect branch surfaces.
|
|
const pending = JSON.parse((await app.inject({ method: 'GET', url: '/asks?status=pending' })).payload) as Ask[];
|
|
expect(pending.map((a) => a.id)).toContain('ASK-0001');
|
|
|
|
const answered = JSON.parse((await app.inject({ method: 'POST', url: '/asks/ASK-0001/answer', payload: { text: 'yes', by: 'claude' } })).payload) as Ask;
|
|
expect(answered.status).toBe('answered');
|
|
expect(answered.answer).toBe('yes');
|
|
|
|
const created2 = JSON.parse((await app.inject({ method: 'POST', url: '/asks', payload: { from: 'kimi', question: 'ship?' } })).payload) as Ask;
|
|
const escalated = JSON.parse((await app.inject({ method: 'POST', url: `/asks/${created2.id}/escalate`, payload: { note: 'ceo call' } })).payload) as Ask;
|
|
expect(escalated.status).toBe('escalated');
|
|
expect(escalated.escalatedTo).toBe('ceo');
|
|
});
|
|
});
|
|
|
|
describe('ask --wait (server SSE)', () => {
|
|
let cwd: string;
|
|
let server: Awaited<ReturnType<typeof startServer>>;
|
|
|
|
beforeEach(async () => {
|
|
cwd = mkdtempSync(join(tmpdir(), 'ah-ask-wait-'));
|
|
init(cwd, { projectName: 'ask-wait', yes: true });
|
|
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await server.app.close().catch(() => undefined);
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
});
|
|
|
|
it('resolves with the answer when the architect answers', async () => {
|
|
const created = (await fetch(`${server.url}/asks`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ from: 'kimi', question: 'which driver?' }),
|
|
}).then((r) => r.json())) as Ask;
|
|
|
|
const waitP = waitForAsk(server.url, created.id, 4);
|
|
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
await fetch(`${server.url}/asks/${created.id}/answer`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text: 'node:sqlite', by: 'claude' }),
|
|
});
|
|
|
|
const settled = await waitP;
|
|
expect(settled).not.toBeNull();
|
|
expect(settled?.status).toBe('answered');
|
|
expect(settled?.answer).toBe('node:sqlite');
|
|
}, 7000);
|
|
|
|
it('times out cleanly when there is no answer', async () => {
|
|
const created = (await fetch(`${server.url}/asks`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ from: 'kimi', question: 'no answer coming' }),
|
|
}).then((r) => r.json())) as Ask;
|
|
|
|
const settled = await waitForAsk(server.url, created.id, 1);
|
|
expect(settled).toBeNull();
|
|
}, 4000);
|
|
});
|