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:
Lukas May
2026-01-31 17:57:34 +01:00
parent 9dd0e46060
commit 151a4c99f7
10 changed files with 200 additions and 148 deletions

View File

@@ -13,7 +13,7 @@ import type {
SpawnAgentOptions, SpawnAgentOptions,
AgentResult, AgentResult,
AgentStatus, AgentStatus,
PendingQuestion, PendingQuestions,
} from './types.js'; } from './types.js';
import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { WorktreeManager } from '../git/types.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 { interface ActiveAgent {
subprocess: ResultPromise; subprocess: ResultPromise;
result?: AgentResult; result?: AgentResult;
pendingQuestion?: PendingQuestion; pendingQuestions?: PendingQuestions;
} }
/** /**
@@ -189,13 +189,11 @@ export class ClaudeAgentManager implements AgentManager {
break; break;
} }
case 'question': { case 'questions': {
// Question path - agent needs input // Questions path - agent needs input (one or more questions)
if (active) { if (active) {
active.pendingQuestion = { active.pendingQuestions = {
question: agentOutput.question, questions: agentOutput.questions,
options: agentOutput.options,
multiSelect: agentOutput.multiSelect,
}; };
} }
await this.repository.updateStatus(agentId, 'waiting_for_input'); await this.repository.updateStatus(agentId, 'waiting_for_input');
@@ -209,9 +207,7 @@ export class ClaudeAgentManager implements AgentManager {
name: agent.name, name: agent.name,
taskId: agent.taskId ?? '', taskId: agent.taskId ?? '',
sessionId: cliResult.session_id ?? agent.sessionId ?? '', sessionId: cliResult.session_id ?? agent.sessionId ?? '',
question: agentOutput.question, questions: agentOutput.questions,
options: agentOutput.options,
multiSelect: agentOutput.multiSelect,
}, },
}; };
this.eventBus.emit(event); 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); const active = this.activeAgents.get(agentId);
return active?.pendingQuestion ?? null; return active?.pendingQuestions ?? null;
} }
/** /**

View File

@@ -211,12 +211,12 @@ describe('MockAgentManager', () => {
// spawn() with question scenario // 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 () => { it('should emit agent:waiting and set status to waiting_for_input', async () => {
manager.setScenario('waiting-agent', { manager.setScenario('waiting-agent', {
status: 'question', status: 'questions',
delay: 0, delay: 0,
question: 'Should I continue?', questions: [{ id: 'q1', question: 'Should I continue?' }],
}); });
const agent = await manager.spawn({ const agent = await manager.spawn({
@@ -234,7 +234,7 @@ describe('MockAgentManager', () => {
// Check event // Check event
const waitingEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:waiting'); const waitingEvent = eventBus.emittedEvents.find((e) => e.type === 'agent:waiting');
expect(waitingEvent).toBeDefined(); 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 // resume() after waiting_for_input
// =========================================================================== // ===========================================================================
describe('resume after question', () => { describe('resume after questions', () => {
it('should emit agent:resumed and continue with scenario', async () => { it('should emit agent:resumed and continue with scenario', async () => {
manager.setScenario('resume-agent', { manager.setScenario('resume-agent', {
status: 'question', status: 'questions',
delay: 0, delay: 0,
question: 'Need your input', questions: [{ id: 'q1', question: 'Need your input' }],
}); });
const agent = await manager.spawn({ const agent = await manager.spawn({
@@ -504,9 +504,9 @@ describe('MockAgentManager', () => {
it('should emit spawned before waiting', async () => { it('should emit spawned before waiting', async () => {
manager.setScenario('wait-order', { manager.setScenario('wait-order', {
status: 'question', status: 'questions',
delay: 0, delay: 0,
question: 'Test question', questions: [{ id: 'q1', question: 'Test question' }],
}); });
await manager.spawn({ name: 'wait-order', taskId: 't1', prompt: 'p1' }); await manager.spawn({ name: 'wait-order', taskId: 't1', prompt: 'p1' });
await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0);
@@ -599,16 +599,21 @@ describe('MockAgentManager', () => {
// Structured question data (new schema tests) // Structured question data (new schema tests)
// =========================================================================== // ===========================================================================
describe('structured question data', () => { describe('structured questions data', () => {
it('emits agent:waiting with structured question data', async () => { it('emits agent:waiting with structured questions data', async () => {
manager.setScenario('test-agent', { manager.setScenario('test-agent', {
status: 'question', status: 'questions',
questions: [
{
id: 'q1',
question: 'Which database?', question: 'Which database?',
options: [ options: [
{ label: 'PostgreSQL', description: 'Full-featured' }, { label: 'PostgreSQL', description: 'Full-featured' },
{ label: 'SQLite', description: 'Lightweight' }, { label: 'SQLite', description: 'Lightweight' },
], ],
multiSelect: false, multiSelect: false,
},
],
}); });
await manager.spawn({ name: 'test-agent', taskId: 'task-1', prompt: 'test' }); 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'); const events = eventBus.emittedEvents.filter((e) => e.type === 'agent:waiting');
expect(events.length).toBe(1); expect(events.length).toBe(1);
expect((events[0] as any).payload.options).toHaveLength(2); expect((events[0] as any).payload.questions).toHaveLength(1);
expect((events[0] as any).payload.options[0].label).toBe('PostgreSQL'); expect((events[0] as any).payload.questions[0].options).toHaveLength(2);
expect((events[0] as any).payload.multiSelect).toBe(false); 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', { manager.setScenario('test-agent', {
status: 'question', status: 'questions',
questions: [
{
id: 'q1',
question: 'Which database?', question: 'Which database?',
options: [{ label: 'PostgreSQL' }], options: [{ label: 'PostgreSQL' }],
},
],
}); });
const agent = await manager.spawn({ name: 'test-agent', taskId: 'task-1', prompt: 'test' }); const agent = await manager.spawn({ name: 'test-agent', taskId: 'task-1', prompt: 'test' });
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
const pending = await manager.getPendingQuestion(agent.id); const pending = await manager.getPendingQuestions(agent.id);
expect(pending?.question).toBe('Which database?'); expect(pending?.questions[0].question).toBe('Which database?');
expect(pending?.options).toHaveLength(1); expect(pending?.questions[0].options).toHaveLength(1);
expect(pending?.options?.[0].label).toBe('PostgreSQL'); 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', { manager.setScenario('resume-test', {
status: 'question', status: 'questions',
questions: [
{
id: 'q1',
question: 'Need your input', question: 'Need your input',
options: [{ label: 'Option A' }, { label: 'Option B' }], options: [{ label: 'Option A' }, { label: 'Option B' }],
},
],
}); });
const agent = await manager.spawn({ name: 'resume-test', taskId: 'task-1', prompt: 'test' }); const agent = await manager.spawn({ name: 'resume-test', taskId: 'task-1', prompt: 'test' });
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
// Verify question is pending // Verify questions are pending
const pendingBefore = await manager.getPendingQuestion(agent.id); const pendingBefore = await manager.getPendingQuestions(agent.id);
expect(pendingBefore).not.toBeNull(); expect(pendingBefore).not.toBeNull();
expect(pendingBefore?.question).toBe('Need your input'); expect(pendingBefore?.questions[0].question).toBe('Need your input');
// Resume the agent // Resume the agent
await manager.resume(agent.id, 'Option A'); await manager.resume(agent.id, 'Option A');
// Pending question should be cleared // Pending questions should be cleared
const pendingAfter = await manager.getPendingQuestion(agent.id); const pendingAfter = await manager.getPendingQuestions(agent.id);
expect(pendingAfter).toBeNull(); expect(pendingAfter).toBeNull();
}); });
it('returns null for non-existent agent pending question', async () => { it('returns null for non-existent agent pending questions', async () => {
const pending = await manager.getPendingQuestion('non-existent-id'); const pending = await manager.getPendingQuestions('non-existent-id');
expect(pending).toBeNull(); expect(pending).toBeNull();
}); });
@@ -669,7 +685,7 @@ describe('MockAgentManager', () => {
const agent = await manager.spawn({ name: 'running-agent', taskId: 'task-1', prompt: 'test' }); const agent = await manager.spawn({ name: 'running-agent', taskId: 'task-1', prompt: 'test' });
// Agent is running, not waiting // Agent is running, not waiting
const pending = await manager.getPendingQuestion(agent.id); const pending = await manager.getPendingQuestions(agent.id);
expect(pending).toBeNull(); expect(pending).toBeNull();
}); });
}); });

View File

@@ -13,7 +13,8 @@ import type {
SpawnAgentOptions, SpawnAgentOptions,
AgentResult, AgentResult,
AgentStatus, AgentStatus,
PendingQuestion, PendingQuestions,
QuestionItem,
} from './types.js'; } from './types.js';
import type { import type {
EventBus, EventBus,
@@ -36,10 +37,8 @@ export type MockAgentScenario =
delay?: number; delay?: number;
} }
| { | {
status: 'question'; status: 'questions';
question: string; questions: QuestionItem[];
options?: Array<{ label: string; description?: string }>;
multiSelect?: boolean;
delay?: number; delay?: number;
} }
| { | {
@@ -56,7 +55,7 @@ interface MockAgentRecord {
info: AgentInfo; info: AgentInfo;
scenario: MockAgentScenario; scenario: MockAgentScenario;
result?: AgentResult; result?: AgentResult;
pendingQuestion?: PendingQuestion; pendingQuestions?: PendingQuestions;
completionTimer?: ReturnType<typeof setTimeout>; completionTimer?: ReturnType<typeof setTimeout>;
} }
@@ -239,13 +238,11 @@ export class MockAgentManager implements AgentManager {
} }
break; break;
case 'question': case 'questions':
record.info.status = 'waiting_for_input'; record.info.status = 'waiting_for_input';
record.info.updatedAt = new Date(); record.info.updatedAt = new Date();
record.pendingQuestion = { record.pendingQuestions = {
question: scenario.question, questions: scenario.questions,
options: scenario.options,
multiSelect: scenario.multiSelect,
}; };
if (this.eventBus) { if (this.eventBus) {
@@ -257,9 +254,7 @@ export class MockAgentManager implements AgentManager {
name: info.name, name: info.name,
taskId: info.taskId, taskId: info.taskId,
sessionId: info.sessionId ?? '', sessionId: info.sessionId ?? '',
question: scenario.question, questions: scenario.questions,
options: scenario.options,
multiSelect: scenario.multiSelect,
}, },
}; };
this.eventBus.emit(event); 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`); 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.status = 'running';
record.info.updatedAt = new Date(); record.info.updatedAt = new Date();
record.pendingQuestion = undefined; record.pendingQuestions = undefined;
// Emit resumed event // Emit resumed event
if (this.eventBus) { 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); const record = this.agents.get(agentId);
return record?.pendingQuestion ?? null; return record?.pendingQuestions ?? null;
} }
/** /**

View File

@@ -15,12 +15,22 @@ const optionSchema = z.object({
description: z.string().optional(), 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. * Discriminated union for agent output.
* *
* Agent must return one of: * Agent must return one of:
* - done: Task completed successfully * - 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 * - unrecoverable_error: Agent hit an error it cannot recover from
*/ */
export const agentOutputSchema = z.discriminatedUnion('status', [ export const agentOutputSchema = z.discriminatedUnion('status', [
@@ -31,12 +41,10 @@ export const agentOutputSchema = z.discriminatedUnion('status', [
filesModified: z.array(z.string()).optional(), filesModified: z.array(z.string()).optional(),
}), }),
// Agent needs user input to continue // Agent needs user input to continue (one or more questions)
z.object({ z.object({
status: z.literal('question'), status: z.literal('questions'),
question: z.string(), questions: z.array(questionItemSchema),
options: z.array(optionSchema).optional(),
multiSelect: z.boolean().optional(),
}), }),
// Agent hit unrecoverable error // Agent hit unrecoverable error
@@ -66,7 +74,13 @@ export const agentOutputJsonSchema = {
}, },
{ {
properties: { properties: {
status: { const: 'question' }, status: { const: 'questions' },
questions: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
question: { type: 'string' }, question: { type: 'string' },
options: { options: {
type: 'array', type: 'array',
@@ -81,7 +95,11 @@ export const agentOutputJsonSchema = {
}, },
multiSelect: { type: 'boolean' }, multiSelect: { type: 'boolean' },
}, },
required: ['status', 'question'], required: ['id', 'question'],
},
},
},
required: ['status', 'questions'],
}, },
{ {
properties: { properties: {

View File

@@ -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 */ /** The question being asked */
question: string; question: string;
/** Optional predefined options for the question */ /** Optional predefined options for the question */
@@ -67,6 +69,14 @@ export interface PendingQuestion {
multiSelect?: boolean; 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 * AgentManager Port Interface
* *
@@ -147,12 +157,12 @@ export interface AgentManager {
getResult(agentId: string): Promise<AgentResult | null>; 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'. * Only available when agent status is 'waiting_for_input'.
* *
* @param agentId - Agent ID * @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>;
} }

View File

@@ -73,7 +73,7 @@ function createMockAgentManager(
stop: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined),
resume: vi.fn().mockResolvedValue(undefined), resume: vi.fn().mockResolvedValue(undefined),
getResult: vi.fn().mockResolvedValue(null), getResult: vi.fn().mockResolvedValue(null),
getPendingQuestion: vi.fn().mockResolvedValue(null), getPendingQuestions: vi.fn().mockResolvedValue(null),
}; };
} }

View File

@@ -183,9 +183,12 @@ export interface AgentWaitingEvent extends DomainEvent {
name: string; name: string;
taskId: string; taskId: string;
sessionId: string; sessionId: string;
questions: Array<{
id: string;
question: string; question: string;
options?: Array<{ label: string; description?: string }>; options?: Array<{ label: string; description?: string }>;
multiSelect?: boolean; multiSelect?: boolean;
}>;
}; };
} }

View File

@@ -151,10 +151,10 @@ describe('E2E Edge Cases', () => {
}); });
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
// Set question scenario // Set questions scenario
harness.setAgentScenario(`agent-${taskAId.slice(0, 6)}`, { harness.setAgentScenario(`agent-${taskAId.slice(0, 6)}`, {
status: 'question', status: 'questions',
question: 'Which database should I use?', questions: [{ id: 'q1', question: 'Which database should I use?' }],
}); });
await harness.dispatchManager.queue(taskAId); await harness.dispatchManager.queue(taskAId);
@@ -168,7 +168,7 @@ describe('E2E Edge Cases', () => {
expect(waitingEvents.length).toBe(1); expect(waitingEvents.length).toBe(1);
const waitingPayload = (waitingEvents[0] as AgentWaitingEvent).payload; const waitingPayload = (waitingEvents[0] as AgentWaitingEvent).payload;
expect(waitingPayload.taskId).toBe(taskAId); 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 () => { it('resumes agent and completes after resume', async () => {
@@ -184,10 +184,10 @@ describe('E2E Edge Cases', () => {
}); });
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
// Set question scenario // Set questions scenario
harness.setAgentScenario(`agent-${taskAId.slice(0, 6)}`, { harness.setAgentScenario(`agent-${taskAId.slice(0, 6)}`, {
status: 'question', status: 'questions',
question: 'Which database should I use?', questions: [{ id: 'q1', question: 'Which database should I use?' }],
}); });
await harness.dispatchManager.queue(taskAId); await harness.dispatchManager.queue(taskAId);
@@ -234,10 +234,10 @@ describe('E2E Edge Cases', () => {
}); });
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
// Set question scenario // Set questions scenario
harness.setAgentScenario(`agent-${taskAId.slice(0, 6)}`, { harness.setAgentScenario(`agent-${taskAId.slice(0, 6)}`, {
status: 'question', status: 'questions',
question: 'Which database should I use?', questions: [{ id: 'q1', question: 'Which database should I use?' }],
}); });
await harness.dispatchManager.queue(taskAId); await harness.dispatchManager.queue(taskAId);

View File

@@ -219,10 +219,16 @@ describe('E2E Recovery Scenarios', () => {
}); });
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
// Set question scenario with options // Set questions scenario with options
harness.setAgentQuestion(`agent-${taskAId.slice(0, 6)}`, 'Which database should I use?', [ harness.setAgentQuestions(`agent-${taskAId.slice(0, 6)}`, [
{
id: 'q1',
question: 'Which database should I use?',
options: [
{ label: 'PostgreSQL', description: 'Relational, ACID compliant' }, { label: 'PostgreSQL', description: 'Relational, ACID compliant' },
{ label: 'SQLite', description: 'Lightweight, file-based' }, { label: 'SQLite', description: 'Lightweight, file-based' },
],
},
]); ]);
// Queue and dispatch // Queue and dispatch
@@ -237,7 +243,7 @@ describe('E2E Recovery Scenarios', () => {
expect(waitingEvents.length).toBe(1); expect(waitingEvents.length).toBe(1);
const waitingPayload = (waitingEvents[0] as AgentWaitingEvent).payload; const waitingPayload = (waitingEvents[0] as AgentWaitingEvent).payload;
expect(waitingPayload.taskId).toBe(taskAId); 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 // Clear and resume
harness.clearEvents(); harness.clearEvents();
@@ -256,7 +262,7 @@ describe('E2E Recovery Scenarios', () => {
expect(stoppedPayload.reason).toBe('task_complete'); expect(stoppedPayload.reason).toBe('task_complete');
}); });
it('question surfaces as structured PendingQuestion', async () => { it('questions surface as structured PendingQuestions', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const seeded = await harness.seedFixture(SIMPLE_FIXTURE); const seeded = await harness.seedFixture(SIMPLE_FIXTURE);
const taskAId = seeded.tasks.get('Task A')!; const taskAId = seeded.tasks.get('Task A')!;
@@ -269,11 +275,17 @@ describe('E2E Recovery Scenarios', () => {
}); });
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
// Set question scenario with options // Set questions scenario with options
harness.setAgentQuestion(`agent-${taskAId.slice(0, 6)}`, 'Select your framework', [ harness.setAgentQuestions(`agent-${taskAId.slice(0, 6)}`, [
{
id: 'q1',
question: 'Select your framework',
options: [
{ label: 'React' }, { label: 'React' },
{ label: 'Vue' }, { label: 'Vue' },
{ label: 'Svelte' }, { label: 'Svelte' },
],
},
]); ]);
// Queue and dispatch // Queue and dispatch
@@ -281,22 +293,22 @@ describe('E2E Recovery Scenarios', () => {
const dispatchResult = await harness.dispatchManager.dispatchNext(); const dispatchResult = await harness.dispatchManager.dispatchNext();
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
// Verify: agent:waiting event has question // Verify: agent:waiting event has questions
const waitingEvents = harness.getEventsByType('agent:waiting'); const waitingEvents = harness.getEventsByType('agent:waiting');
expect(waitingEvents.length).toBe(1); expect(waitingEvents.length).toBe(1);
const waitingPayload = (waitingEvents[0] as AgentWaitingEvent).payload; const waitingPayload = (waitingEvents[0] as AgentWaitingEvent).payload;
expect(waitingPayload.question).toBe('Select your framework'); expect(waitingPayload.questions[0].question).toBe('Select your framework');
expect(waitingPayload.options).toEqual([ expect(waitingPayload.questions[0].options).toEqual([
{ label: 'React' }, { label: 'React' },
{ label: 'Vue' }, { label: 'Vue' },
{ label: 'Svelte' }, { label: 'Svelte' },
]); ]);
// Verify: getPendingQuestion returns structured data // Verify: getPendingQuestions returns structured data
const pendingQuestion = await harness.getPendingQuestion(dispatchResult.agentId!); const pendingQuestions = await harness.getPendingQuestions(dispatchResult.agentId!);
expect(pendingQuestion).not.toBeNull(); expect(pendingQuestions).not.toBeNull();
expect(pendingQuestion?.question).toBe('Select your framework'); expect(pendingQuestions?.questions[0].question).toBe('Select your framework');
expect(pendingQuestion?.options).toEqual([ expect(pendingQuestions?.questions[0].options).toEqual([
{ label: 'React' }, { label: 'React' },
{ label: 'Vue' }, { label: 'Vue' },
{ label: 'Svelte' }, { label: 'Svelte' },
@@ -316,8 +328,10 @@ describe('E2E Recovery Scenarios', () => {
}); });
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
// Set question scenario // Set questions scenario
harness.setAgentQuestion(`agent-${taskAId.slice(0, 6)}`, 'Choose database type'); harness.setAgentQuestions(`agent-${taskAId.slice(0, 6)}`, [
{ id: 'q1', question: 'Choose database type' },
]);
// Queue and dispatch // Queue and dispatch
await harness.dispatchManager.queue(taskAId); await harness.dispatchManager.queue(taskAId);
@@ -356,8 +370,10 @@ describe('E2E Recovery Scenarios', () => {
}); });
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
// Set question scenario // Set questions scenario
harness.setAgentQuestion(`agent-${taskAId.slice(0, 6)}`, 'API key format?'); harness.setAgentQuestions(`agent-${taskAId.slice(0, 6)}`, [
{ id: 'q1', question: 'API key format?' },
]);
// Queue and dispatch // Queue and dispatch
await harness.dispatchManager.queue(taskAId); await harness.dispatchManager.queue(taskAId);
@@ -373,9 +389,9 @@ describe('E2E Recovery Scenarios', () => {
agent = await harness.agentManager.get(dispatchResult.agentId!); agent = await harness.agentManager.get(dispatchResult.agentId!);
expect(agent?.status).toBe('waiting_for_input'); expect(agent?.status).toBe('waiting_for_input');
// Verify pending question exists // Verify pending questions exist
const pendingQuestion = await harness.getPendingQuestion(dispatchResult.agentId!); const pendingQuestions = await harness.getPendingQuestions(dispatchResult.agentId!);
expect(pendingQuestion?.question).toBe('API key format?'); expect(pendingQuestions?.questions[0].question).toBe('API key format?');
// Phase 3: Resume // Phase 3: Resume
await harness.agentManager.resume(dispatchResult.agentId!, 'Bearer token'); await harness.agentManager.resume(dispatchResult.agentId!, 'Bearer token');
@@ -390,9 +406,9 @@ describe('E2E Recovery Scenarios', () => {
agent = await harness.agentManager.get(dispatchResult.agentId!); agent = await harness.agentManager.get(dispatchResult.agentId!);
expect(agent?.status).toBe('idle'); expect(agent?.status).toBe('idle');
// Verify pending question is cleared after resume // Verify pending questions is cleared after resume
const clearedQuestion = await harness.getPendingQuestion(dispatchResult.agentId!); const clearedQuestions = await harness.getPendingQuestions(dispatchResult.agentId!);
expect(clearedQuestion).toBeNull(); expect(clearedQuestions).toBeNull();
}); });
}); });
}); });

View File

@@ -12,7 +12,7 @@ import type { EventBus, DomainEvent } from '../events/types.js';
import { EventEmitterBus } from '../events/bus.js'; import { EventEmitterBus } from '../events/bus.js';
import type { AgentManager } from '../agent/types.js'; import type { AgentManager } from '../agent/types.js';
import { MockAgentManager, type MockAgentScenario } from '../agent/mock-manager.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 { WorktreeManager, Worktree, WorktreeDiff, MergeResult } from '../git/types.js';
import type { DispatchManager } from '../dispatch/types.js'; import type { DispatchManager } from '../dispatch/types.js';
import { DefaultDispatchManager } from '../dispatch/manager.js'; import { DefaultDispatchManager } from '../dispatch/manager.js';
@@ -197,12 +197,11 @@ export interface TestHarness {
setAgentDone(agentName: string, result?: string): void; setAgentDone(agentName: string, result?: string): void;
/** /**
* Convenience: Set agent to ask a question. * Convenience: Set agent to ask questions.
*/ */
setAgentQuestion( setAgentQuestions(
agentName: string, agentName: string,
question: string, questions: QuestionItem[]
options?: Array<{ label: string; description?: string }>
): void; ): void;
/** /**
@@ -211,9 +210,9 @@ export interface TestHarness {
setAgentError(agentName: string, error: string): void; 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. * Get events by type.
@@ -305,19 +304,18 @@ export function createTestHarness(): TestHarness {
agentManager.setScenario(agentName, { status: 'done', result }); agentManager.setScenario(agentName, { status: 'done', result });
}, },
setAgentQuestion: ( setAgentQuestions: (
agentName: string, agentName: string,
question: string, questions: QuestionItem[]
options?: Array<{ label: string; description?: string }>
) => { ) => {
agentManager.setScenario(agentName, { status: 'question', question, options }); agentManager.setScenario(agentName, { status: 'questions', questions });
}, },
setAgentError: (agentName: string, error: string) => { setAgentError: (agentName: string, error: string) => {
agentManager.setScenario(agentName, { status: 'unrecoverable_error', error }); 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), getEventsByType: (type: string) => eventBus.getEventsByType(type),