feat(10-01): extend agent schema to multi-question array
- Change status from 'question' to 'questions' (plural) - Add QuestionItem with id field for answer matching - Update PendingQuestion to PendingQuestions with questions array - Update AgentWaitingEvent payload to questions array - Update ClaudeAgentManager and MockAgentManager adapters - Update TestHarness and all test files
This commit is contained in:
@@ -13,7 +13,7 @@ import type {
|
||||
SpawnAgentOptions,
|
||||
AgentResult,
|
||||
AgentStatus,
|
||||
PendingQuestion,
|
||||
PendingQuestions,
|
||||
} from './types.js';
|
||||
import type { AgentRepository } from '../db/repositories/agent-repository.js';
|
||||
import type { WorktreeManager } from '../git/types.js';
|
||||
@@ -40,12 +40,12 @@ interface ClaudeCliResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks an active agent subprocess, its result, and any pending question
|
||||
* Tracks an active agent subprocess, its result, and any pending questions
|
||||
*/
|
||||
interface ActiveAgent {
|
||||
subprocess: ResultPromise;
|
||||
result?: AgentResult;
|
||||
pendingQuestion?: PendingQuestion;
|
||||
pendingQuestions?: PendingQuestions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,13 +189,11 @@ export class ClaudeAgentManager implements AgentManager {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'question': {
|
||||
// Question path - agent needs input
|
||||
case 'questions': {
|
||||
// Questions path - agent needs input (one or more questions)
|
||||
if (active) {
|
||||
active.pendingQuestion = {
|
||||
question: agentOutput.question,
|
||||
options: agentOutput.options,
|
||||
multiSelect: agentOutput.multiSelect,
|
||||
active.pendingQuestions = {
|
||||
questions: agentOutput.questions,
|
||||
};
|
||||
}
|
||||
await this.repository.updateStatus(agentId, 'waiting_for_input');
|
||||
@@ -209,9 +207,7 @@ export class ClaudeAgentManager implements AgentManager {
|
||||
name: agent.name,
|
||||
taskId: agent.taskId ?? '',
|
||||
sessionId: cliResult.session_id ?? agent.sessionId ?? '',
|
||||
question: agentOutput.question,
|
||||
options: agentOutput.options,
|
||||
multiSelect: agentOutput.multiSelect,
|
||||
questions: agentOutput.questions,
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
@@ -423,11 +419,11 @@ export class ClaudeAgentManager implements AgentManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending question for an agent waiting for input.
|
||||
* Get pending questions for an agent waiting for input.
|
||||
*/
|
||||
async getPendingQuestion(agentId: string): Promise<PendingQuestion | null> {
|
||||
async getPendingQuestions(agentId: string): Promise<PendingQuestions | null> {
|
||||
const active = this.activeAgents.get(agentId);
|
||||
return active?.pendingQuestion ?? null;
|
||||
return active?.pendingQuestions ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -211,12 +211,12 @@ describe('MockAgentManager', () => {
|
||||
// spawn() with question scenario
|
||||
// ===========================================================================
|
||||
|
||||
describe('spawn with question scenario', () => {
|
||||
describe('spawn with questions scenario', () => {
|
||||
it('should emit agent:waiting and set status to waiting_for_input', async () => {
|
||||
manager.setScenario('waiting-agent', {
|
||||
status: 'question',
|
||||
status: 'questions',
|
||||
delay: 0,
|
||||
question: 'Should I continue?',
|
||||
questions: [{ id: 'q1', question: 'Should I continue?' }],
|
||||
});
|
||||
|
||||
const agent = await manager.spawn({
|
||||
@@ -234,7 +234,7 @@ describe('MockAgentManager', () => {
|
||||
// Check event
|
||||
const waitingEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:waiting');
|
||||
expect(waitingEvent).toBeDefined();
|
||||
expect((waitingEvent as any).payload.question).toBe('Should I continue?');
|
||||
expect((waitingEvent as any).payload.questions[0].question).toBe('Should I continue?');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -242,12 +242,12 @@ describe('MockAgentManager', () => {
|
||||
// resume() after waiting_for_input
|
||||
// ===========================================================================
|
||||
|
||||
describe('resume after question', () => {
|
||||
describe('resume after questions', () => {
|
||||
it('should emit agent:resumed and continue with scenario', async () => {
|
||||
manager.setScenario('resume-agent', {
|
||||
status: 'question',
|
||||
status: 'questions',
|
||||
delay: 0,
|
||||
question: 'Need your input',
|
||||
questions: [{ id: 'q1', question: 'Need your input' }],
|
||||
});
|
||||
|
||||
const agent = await manager.spawn({
|
||||
@@ -504,9 +504,9 @@ describe('MockAgentManager', () => {
|
||||
|
||||
it('should emit spawned before waiting', async () => {
|
||||
manager.setScenario('wait-order', {
|
||||
status: 'question',
|
||||
status: 'questions',
|
||||
delay: 0,
|
||||
question: 'Test question',
|
||||
questions: [{ id: 'q1', question: 'Test question' }],
|
||||
});
|
||||
await manager.spawn({ name: 'wait-order', taskId: 't1', prompt: 'p1' });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
@@ -599,16 +599,21 @@ describe('MockAgentManager', () => {
|
||||
// Structured question data (new schema tests)
|
||||
// ===========================================================================
|
||||
|
||||
describe('structured question data', () => {
|
||||
it('emits agent:waiting with structured question data', async () => {
|
||||
describe('structured questions data', () => {
|
||||
it('emits agent:waiting with structured questions data', async () => {
|
||||
manager.setScenario('test-agent', {
|
||||
status: 'question',
|
||||
question: 'Which database?',
|
||||
options: [
|
||||
{ label: 'PostgreSQL', description: 'Full-featured' },
|
||||
{ label: 'SQLite', description: 'Lightweight' },
|
||||
status: 'questions',
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
question: 'Which database?',
|
||||
options: [
|
||||
{ label: 'PostgreSQL', description: 'Full-featured' },
|
||||
{ label: 'SQLite', description: 'Lightweight' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
});
|
||||
|
||||
await manager.spawn({ name: 'test-agent', taskId: 'task-1', prompt: 'test' });
|
||||
@@ -616,52 +621,63 @@ describe('MockAgentManager', () => {
|
||||
|
||||
const events = eventBus.emittedEvents.filter((e) => e.type === 'agent:waiting');
|
||||
expect(events.length).toBe(1);
|
||||
expect((events[0] as any).payload.options).toHaveLength(2);
|
||||
expect((events[0] as any).payload.options[0].label).toBe('PostgreSQL');
|
||||
expect((events[0] as any).payload.multiSelect).toBe(false);
|
||||
expect((events[0] as any).payload.questions).toHaveLength(1);
|
||||
expect((events[0] as any).payload.questions[0].options).toHaveLength(2);
|
||||
expect((events[0] as any).payload.questions[0].options[0].label).toBe('PostgreSQL');
|
||||
expect((events[0] as any).payload.questions[0].multiSelect).toBe(false);
|
||||
});
|
||||
|
||||
it('stores pending question for retrieval', async () => {
|
||||
it('stores pending questions for retrieval', async () => {
|
||||
manager.setScenario('test-agent', {
|
||||
status: 'question',
|
||||
question: 'Which database?',
|
||||
options: [{ label: 'PostgreSQL' }],
|
||||
status: 'questions',
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
question: 'Which database?',
|
||||
options: [{ label: 'PostgreSQL' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const agent = await manager.spawn({ name: 'test-agent', taskId: 'task-1', prompt: 'test' });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const pending = await manager.getPendingQuestion(agent.id);
|
||||
expect(pending?.question).toBe('Which database?');
|
||||
expect(pending?.options).toHaveLength(1);
|
||||
expect(pending?.options?.[0].label).toBe('PostgreSQL');
|
||||
const pending = await manager.getPendingQuestions(agent.id);
|
||||
expect(pending?.questions[0].question).toBe('Which database?');
|
||||
expect(pending?.questions[0].options).toHaveLength(1);
|
||||
expect(pending?.questions[0].options?.[0].label).toBe('PostgreSQL');
|
||||
});
|
||||
|
||||
it('clears pending question after resume', async () => {
|
||||
it('clears pending questions after resume', async () => {
|
||||
manager.setScenario('resume-test', {
|
||||
status: 'question',
|
||||
question: 'Need your input',
|
||||
options: [{ label: 'Option A' }, { label: 'Option B' }],
|
||||
status: 'questions',
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
question: 'Need your input',
|
||||
options: [{ label: 'Option A' }, { label: 'Option B' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const agent = await manager.spawn({ name: 'resume-test', taskId: 'task-1', prompt: 'test' });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Verify question is pending
|
||||
const pendingBefore = await manager.getPendingQuestion(agent.id);
|
||||
// Verify questions are pending
|
||||
const pendingBefore = await manager.getPendingQuestions(agent.id);
|
||||
expect(pendingBefore).not.toBeNull();
|
||||
expect(pendingBefore?.question).toBe('Need your input');
|
||||
expect(pendingBefore?.questions[0].question).toBe('Need your input');
|
||||
|
||||
// Resume the agent
|
||||
await manager.resume(agent.id, 'Option A');
|
||||
|
||||
// Pending question should be cleared
|
||||
const pendingAfter = await manager.getPendingQuestion(agent.id);
|
||||
// Pending questions should be cleared
|
||||
const pendingAfter = await manager.getPendingQuestions(agent.id);
|
||||
expect(pendingAfter).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-existent agent pending question', async () => {
|
||||
const pending = await manager.getPendingQuestion('non-existent-id');
|
||||
it('returns null for non-existent agent pending questions', async () => {
|
||||
const pending = await manager.getPendingQuestions('non-existent-id');
|
||||
expect(pending).toBeNull();
|
||||
});
|
||||
|
||||
@@ -669,7 +685,7 @@ describe('MockAgentManager', () => {
|
||||
const agent = await manager.spawn({ name: 'running-agent', taskId: 'task-1', prompt: 'test' });
|
||||
|
||||
// Agent is running, not waiting
|
||||
const pending = await manager.getPendingQuestion(agent.id);
|
||||
const pending = await manager.getPendingQuestions(agent.id);
|
||||
expect(pending).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,8 @@ import type {
|
||||
SpawnAgentOptions,
|
||||
AgentResult,
|
||||
AgentStatus,
|
||||
PendingQuestion,
|
||||
PendingQuestions,
|
||||
QuestionItem,
|
||||
} from './types.js';
|
||||
import type {
|
||||
EventBus,
|
||||
@@ -36,10 +37,8 @@ export type MockAgentScenario =
|
||||
delay?: number;
|
||||
}
|
||||
| {
|
||||
status: 'question';
|
||||
question: string;
|
||||
options?: Array<{ label: string; description?: string }>;
|
||||
multiSelect?: boolean;
|
||||
status: 'questions';
|
||||
questions: QuestionItem[];
|
||||
delay?: number;
|
||||
}
|
||||
| {
|
||||
@@ -56,7 +55,7 @@ interface MockAgentRecord {
|
||||
info: AgentInfo;
|
||||
scenario: MockAgentScenario;
|
||||
result?: AgentResult;
|
||||
pendingQuestion?: PendingQuestion;
|
||||
pendingQuestions?: PendingQuestions;
|
||||
completionTimer?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
@@ -239,13 +238,11 @@ export class MockAgentManager implements AgentManager {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'question':
|
||||
case 'questions':
|
||||
record.info.status = 'waiting_for_input';
|
||||
record.info.updatedAt = new Date();
|
||||
record.pendingQuestion = {
|
||||
question: scenario.question,
|
||||
options: scenario.options,
|
||||
multiSelect: scenario.multiSelect,
|
||||
record.pendingQuestions = {
|
||||
questions: scenario.questions,
|
||||
};
|
||||
|
||||
if (this.eventBus) {
|
||||
@@ -257,9 +254,7 @@ export class MockAgentManager implements AgentManager {
|
||||
name: info.name,
|
||||
taskId: info.taskId,
|
||||
sessionId: info.sessionId ?? '',
|
||||
question: scenario.question,
|
||||
options: scenario.options,
|
||||
multiSelect: scenario.multiSelect,
|
||||
questions: scenario.questions,
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
@@ -352,10 +347,10 @@ export class MockAgentManager implements AgentManager {
|
||||
throw new Error(`Agent '${record.info.name}' has no session to resume`);
|
||||
}
|
||||
|
||||
// Update status to running, clear pending question
|
||||
// Update status to running, clear pending questions
|
||||
record.info.status = 'running';
|
||||
record.info.updatedAt = new Date();
|
||||
record.pendingQuestion = undefined;
|
||||
record.pendingQuestions = undefined;
|
||||
|
||||
// Emit resumed event
|
||||
if (this.eventBus) {
|
||||
@@ -398,11 +393,11 @@ export class MockAgentManager implements AgentManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending question for an agent waiting for input.
|
||||
* Get pending questions for an agent waiting for input.
|
||||
*/
|
||||
async getPendingQuestion(agentId: string): Promise<PendingQuestion | null> {
|
||||
async getPendingQuestions(agentId: string): Promise<PendingQuestions | null> {
|
||||
const record = this.agents.get(agentId);
|
||||
return record?.pendingQuestion ?? null;
|
||||
return record?.pendingQuestions ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,12 +15,22 @@ const optionSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Individual question item with unique ID for answer matching
|
||||
*/
|
||||
const questionItemSchema = z.object({
|
||||
id: z.string(),
|
||||
question: z.string(),
|
||||
options: z.array(optionSchema).optional(),
|
||||
multiSelect: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Discriminated union for agent output.
|
||||
*
|
||||
* Agent must return one of:
|
||||
* - done: Task completed successfully
|
||||
* - question: Agent needs user input to continue
|
||||
* - questions: Agent needs user input to continue (supports multiple questions)
|
||||
* - unrecoverable_error: Agent hit an error it cannot recover from
|
||||
*/
|
||||
export const agentOutputSchema = z.discriminatedUnion('status', [
|
||||
@@ -31,12 +41,10 @@ export const agentOutputSchema = z.discriminatedUnion('status', [
|
||||
filesModified: z.array(z.string()).optional(),
|
||||
}),
|
||||
|
||||
// Agent needs user input to continue
|
||||
// Agent needs user input to continue (one or more questions)
|
||||
z.object({
|
||||
status: z.literal('question'),
|
||||
question: z.string(),
|
||||
options: z.array(optionSchema).optional(),
|
||||
multiSelect: z.boolean().optional(),
|
||||
status: z.literal('questions'),
|
||||
questions: z.array(questionItemSchema),
|
||||
}),
|
||||
|
||||
// Agent hit unrecoverable error
|
||||
@@ -66,22 +74,32 @@ export const agentOutputJsonSchema = {
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
status: { const: 'question' },
|
||||
question: { type: 'string' },
|
||||
options: {
|
||||
status: { const: 'questions' },
|
||||
questions: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
id: { type: 'string' },
|
||||
question: { type: 'string' },
|
||||
options: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
},
|
||||
required: ['label'],
|
||||
},
|
||||
},
|
||||
multiSelect: { type: 'boolean' },
|
||||
},
|
||||
required: ['label'],
|
||||
required: ['id', 'question'],
|
||||
},
|
||||
},
|
||||
multiSelect: { type: 'boolean' },
|
||||
},
|
||||
required: ['status', 'question'],
|
||||
required: ['status', 'questions'],
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
|
||||
@@ -56,9 +56,11 @@ export interface AgentResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending question when agent is waiting for input
|
||||
* Individual question item with unique ID for answer matching
|
||||
*/
|
||||
export interface PendingQuestion {
|
||||
export interface QuestionItem {
|
||||
/** Unique identifier for matching answers */
|
||||
id: string;
|
||||
/** The question being asked */
|
||||
question: string;
|
||||
/** Optional predefined options for the question */
|
||||
@@ -67,6 +69,14 @@ export interface PendingQuestion {
|
||||
multiSelect?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending questions when agent is waiting for input
|
||||
*/
|
||||
export interface PendingQuestions {
|
||||
/** Array of questions the agent is asking */
|
||||
questions: QuestionItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentManager Port Interface
|
||||
*
|
||||
@@ -147,12 +157,12 @@ export interface AgentManager {
|
||||
getResult(agentId: string): Promise<AgentResult | null>;
|
||||
|
||||
/**
|
||||
* Get pending question for an agent waiting for input.
|
||||
* Get pending questions for an agent waiting for input.
|
||||
*
|
||||
* Only available when agent status is 'waiting_for_input'.
|
||||
*
|
||||
* @param agentId - Agent ID
|
||||
* @returns Pending question if available, null otherwise
|
||||
* @returns Pending questions if available, null otherwise
|
||||
*/
|
||||
getPendingQuestion(agentId: string): Promise<PendingQuestion | null>;
|
||||
getPendingQuestions(agentId: string): Promise<PendingQuestions | null>;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ function createMockAgentManager(
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
resume: vi.fn().mockResolvedValue(undefined),
|
||||
getResult: vi.fn().mockResolvedValue(null),
|
||||
getPendingQuestion: vi.fn().mockResolvedValue(null),
|
||||
getPendingQuestions: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -183,9 +183,12 @@ export interface AgentWaitingEvent extends DomainEvent {
|
||||
name: string;
|
||||
taskId: string;
|
||||
sessionId: string;
|
||||
question: string;
|
||||
options?: Array<{ label: string; description?: string }>;
|
||||
multiSelect?: boolean;
|
||||
questions: Array<{
|
||||
id: string;
|
||||
question: string;
|
||||
options?: Array<{ label: string; description?: string }>;
|
||||
multiSelect?: boolean;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -151,10 +151,10 @@ describe('E2E Edge Cases', () => {
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Set question scenario
|
||||
// Set questions scenario
|
||||
harness.setAgentScenario(`agent-${taskAId.slice(0, 6)}`, {
|
||||
status: 'question',
|
||||
question: 'Which database should I use?',
|
||||
status: 'questions',
|
||||
questions: [{ id: 'q1', question: 'Which database should I use?' }],
|
||||
});
|
||||
|
||||
await harness.dispatchManager.queue(taskAId);
|
||||
@@ -168,7 +168,7 @@ describe('E2E Edge Cases', () => {
|
||||
expect(waitingEvents.length).toBe(1);
|
||||
const waitingPayload = (waitingEvents[0] as AgentWaitingEvent).payload;
|
||||
expect(waitingPayload.taskId).toBe(taskAId);
|
||||
expect(waitingPayload.question).toBe('Which database should I use?');
|
||||
expect(waitingPayload.questions[0].question).toBe('Which database should I use?');
|
||||
});
|
||||
|
||||
it('resumes agent and completes after resume', async () => {
|
||||
@@ -184,10 +184,10 @@ describe('E2E Edge Cases', () => {
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Set question scenario
|
||||
// Set questions scenario
|
||||
harness.setAgentScenario(`agent-${taskAId.slice(0, 6)}`, {
|
||||
status: 'question',
|
||||
question: 'Which database should I use?',
|
||||
status: 'questions',
|
||||
questions: [{ id: 'q1', question: 'Which database should I use?' }],
|
||||
});
|
||||
|
||||
await harness.dispatchManager.queue(taskAId);
|
||||
@@ -234,10 +234,10 @@ describe('E2E Edge Cases', () => {
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Set question scenario
|
||||
// Set questions scenario
|
||||
harness.setAgentScenario(`agent-${taskAId.slice(0, 6)}`, {
|
||||
status: 'question',
|
||||
question: 'Which database should I use?',
|
||||
status: 'questions',
|
||||
questions: [{ id: 'q1', question: 'Which database should I use?' }],
|
||||
});
|
||||
|
||||
await harness.dispatchManager.queue(taskAId);
|
||||
|
||||
@@ -219,10 +219,16 @@ describe('E2E Recovery Scenarios', () => {
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Set question scenario with options
|
||||
harness.setAgentQuestion(`agent-${taskAId.slice(0, 6)}`, 'Which database should I use?', [
|
||||
{ label: 'PostgreSQL', description: 'Relational, ACID compliant' },
|
||||
{ label: 'SQLite', description: 'Lightweight, file-based' },
|
||||
// Set questions scenario with options
|
||||
harness.setAgentQuestions(`agent-${taskAId.slice(0, 6)}`, [
|
||||
{
|
||||
id: 'q1',
|
||||
question: 'Which database should I use?',
|
||||
options: [
|
||||
{ label: 'PostgreSQL', description: 'Relational, ACID compliant' },
|
||||
{ label: 'SQLite', description: 'Lightweight, file-based' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// Queue and dispatch
|
||||
@@ -237,7 +243,7 @@ describe('E2E Recovery Scenarios', () => {
|
||||
expect(waitingEvents.length).toBe(1);
|
||||
const waitingPayload = (waitingEvents[0] as AgentWaitingEvent).payload;
|
||||
expect(waitingPayload.taskId).toBe(taskAId);
|
||||
expect(waitingPayload.question).toBe('Which database should I use?');
|
||||
expect(waitingPayload.questions[0].question).toBe('Which database should I use?');
|
||||
|
||||
// Clear and resume
|
||||
harness.clearEvents();
|
||||
@@ -256,7 +262,7 @@ describe('E2E Recovery Scenarios', () => {
|
||||
expect(stoppedPayload.reason).toBe('task_complete');
|
||||
});
|
||||
|
||||
it('question surfaces as structured PendingQuestion', async () => {
|
||||
it('questions surface as structured PendingQuestions', async () => {
|
||||
vi.useFakeTimers();
|
||||
const seeded = await harness.seedFixture(SIMPLE_FIXTURE);
|
||||
const taskAId = seeded.tasks.get('Task A')!;
|
||||
@@ -269,11 +275,17 @@ describe('E2E Recovery Scenarios', () => {
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Set question scenario with options
|
||||
harness.setAgentQuestion(`agent-${taskAId.slice(0, 6)}`, 'Select your framework', [
|
||||
{ label: 'React' },
|
||||
{ label: 'Vue' },
|
||||
{ label: 'Svelte' },
|
||||
// Set questions scenario with options
|
||||
harness.setAgentQuestions(`agent-${taskAId.slice(0, 6)}`, [
|
||||
{
|
||||
id: 'q1',
|
||||
question: 'Select your framework',
|
||||
options: [
|
||||
{ label: 'React' },
|
||||
{ label: 'Vue' },
|
||||
{ label: 'Svelte' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// Queue and dispatch
|
||||
@@ -281,22 +293,22 @@ describe('E2E Recovery Scenarios', () => {
|
||||
const dispatchResult = await harness.dispatchManager.dispatchNext();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Verify: agent:waiting event has question
|
||||
// Verify: agent:waiting event has questions
|
||||
const waitingEvents = harness.getEventsByType('agent:waiting');
|
||||
expect(waitingEvents.length).toBe(1);
|
||||
const waitingPayload = (waitingEvents[0] as AgentWaitingEvent).payload;
|
||||
expect(waitingPayload.question).toBe('Select your framework');
|
||||
expect(waitingPayload.options).toEqual([
|
||||
expect(waitingPayload.questions[0].question).toBe('Select your framework');
|
||||
expect(waitingPayload.questions[0].options).toEqual([
|
||||
{ label: 'React' },
|
||||
{ label: 'Vue' },
|
||||
{ label: 'Svelte' },
|
||||
]);
|
||||
|
||||
// Verify: getPendingQuestion returns structured data
|
||||
const pendingQuestion = await harness.getPendingQuestion(dispatchResult.agentId!);
|
||||
expect(pendingQuestion).not.toBeNull();
|
||||
expect(pendingQuestion?.question).toBe('Select your framework');
|
||||
expect(pendingQuestion?.options).toEqual([
|
||||
// Verify: getPendingQuestions returns structured data
|
||||
const pendingQuestions = await harness.getPendingQuestions(dispatchResult.agentId!);
|
||||
expect(pendingQuestions).not.toBeNull();
|
||||
expect(pendingQuestions?.questions[0].question).toBe('Select your framework');
|
||||
expect(pendingQuestions?.questions[0].options).toEqual([
|
||||
{ label: 'React' },
|
||||
{ label: 'Vue' },
|
||||
{ label: 'Svelte' },
|
||||
@@ -316,8 +328,10 @@ describe('E2E Recovery Scenarios', () => {
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Set question scenario
|
||||
harness.setAgentQuestion(`agent-${taskAId.slice(0, 6)}`, 'Choose database type');
|
||||
// Set questions scenario
|
||||
harness.setAgentQuestions(`agent-${taskAId.slice(0, 6)}`, [
|
||||
{ id: 'q1', question: 'Choose database type' },
|
||||
]);
|
||||
|
||||
// Queue and dispatch
|
||||
await harness.dispatchManager.queue(taskAId);
|
||||
@@ -356,8 +370,10 @@ describe('E2E Recovery Scenarios', () => {
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Set question scenario
|
||||
harness.setAgentQuestion(`agent-${taskAId.slice(0, 6)}`, 'API key format?');
|
||||
// Set questions scenario
|
||||
harness.setAgentQuestions(`agent-${taskAId.slice(0, 6)}`, [
|
||||
{ id: 'q1', question: 'API key format?' },
|
||||
]);
|
||||
|
||||
// Queue and dispatch
|
||||
await harness.dispatchManager.queue(taskAId);
|
||||
@@ -373,9 +389,9 @@ describe('E2E Recovery Scenarios', () => {
|
||||
agent = await harness.agentManager.get(dispatchResult.agentId!);
|
||||
expect(agent?.status).toBe('waiting_for_input');
|
||||
|
||||
// Verify pending question exists
|
||||
const pendingQuestion = await harness.getPendingQuestion(dispatchResult.agentId!);
|
||||
expect(pendingQuestion?.question).toBe('API key format?');
|
||||
// Verify pending questions exist
|
||||
const pendingQuestions = await harness.getPendingQuestions(dispatchResult.agentId!);
|
||||
expect(pendingQuestions?.questions[0].question).toBe('API key format?');
|
||||
|
||||
// Phase 3: Resume
|
||||
await harness.agentManager.resume(dispatchResult.agentId!, 'Bearer token');
|
||||
@@ -390,9 +406,9 @@ describe('E2E Recovery Scenarios', () => {
|
||||
agent = await harness.agentManager.get(dispatchResult.agentId!);
|
||||
expect(agent?.status).toBe('idle');
|
||||
|
||||
// Verify pending question is cleared after resume
|
||||
const clearedQuestion = await harness.getPendingQuestion(dispatchResult.agentId!);
|
||||
expect(clearedQuestion).toBeNull();
|
||||
// Verify pending questions is cleared after resume
|
||||
const clearedQuestions = await harness.getPendingQuestions(dispatchResult.agentId!);
|
||||
expect(clearedQuestions).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { EventBus, DomainEvent } from '../events/types.js';
|
||||
import { EventEmitterBus } from '../events/bus.js';
|
||||
import type { AgentManager } from '../agent/types.js';
|
||||
import { MockAgentManager, type MockAgentScenario } from '../agent/mock-manager.js';
|
||||
import type { PendingQuestion } from '../agent/types.js';
|
||||
import type { PendingQuestions, QuestionItem } from '../agent/types.js';
|
||||
import type { WorktreeManager, Worktree, WorktreeDiff, MergeResult } from '../git/types.js';
|
||||
import type { DispatchManager } from '../dispatch/types.js';
|
||||
import { DefaultDispatchManager } from '../dispatch/manager.js';
|
||||
@@ -197,12 +197,11 @@ export interface TestHarness {
|
||||
setAgentDone(agentName: string, result?: string): void;
|
||||
|
||||
/**
|
||||
* Convenience: Set agent to ask a question.
|
||||
* Convenience: Set agent to ask questions.
|
||||
*/
|
||||
setAgentQuestion(
|
||||
setAgentQuestions(
|
||||
agentName: string,
|
||||
question: string,
|
||||
options?: Array<{ label: string; description?: string }>
|
||||
questions: QuestionItem[]
|
||||
): void;
|
||||
|
||||
/**
|
||||
@@ -211,9 +210,9 @@ export interface TestHarness {
|
||||
setAgentError(agentName: string, error: string): void;
|
||||
|
||||
/**
|
||||
* Get pending question for an agent.
|
||||
* Get pending questions for an agent.
|
||||
*/
|
||||
getPendingQuestion(agentId: string): Promise<PendingQuestion | null>;
|
||||
getPendingQuestions(agentId: string): Promise<PendingQuestions | null>;
|
||||
|
||||
/**
|
||||
* Get events by type.
|
||||
@@ -305,19 +304,18 @@ export function createTestHarness(): TestHarness {
|
||||
agentManager.setScenario(agentName, { status: 'done', result });
|
||||
},
|
||||
|
||||
setAgentQuestion: (
|
||||
setAgentQuestions: (
|
||||
agentName: string,
|
||||
question: string,
|
||||
options?: Array<{ label: string; description?: string }>
|
||||
questions: QuestionItem[]
|
||||
) => {
|
||||
agentManager.setScenario(agentName, { status: 'question', question, options });
|
||||
agentManager.setScenario(agentName, { status: 'questions', questions });
|
||||
},
|
||||
|
||||
setAgentError: (agentName: string, error: string) => {
|
||||
agentManager.setScenario(agentName, { status: 'unrecoverable_error', error });
|
||||
},
|
||||
|
||||
getPendingQuestion: (agentId: string) => agentManager.getPendingQuestion(agentId),
|
||||
getPendingQuestions: (agentId: string) => agentManager.getPendingQuestions(agentId),
|
||||
|
||||
getEventsByType: (type: string) => eventBus.getEventsByType(type),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user