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,
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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',
|
||||||
question: 'Which database?',
|
questions: [
|
||||||
options: [
|
{
|
||||||
{ label: 'PostgreSQL', description: 'Full-featured' },
|
id: 'q1',
|
||||||
{ label: 'SQLite', description: 'Lightweight' },
|
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' });
|
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',
|
||||||
question: 'Which database?',
|
questions: [
|
||||||
options: [{ label: 'PostgreSQL' }],
|
{
|
||||||
|
id: 'q1',
|
||||||
|
question: 'Which database?',
|
||||||
|
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',
|
||||||
question: 'Need your input',
|
questions: [
|
||||||
options: [{ label: 'Option A' }, { label: 'Option B' }],
|
{
|
||||||
|
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' });
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,22 +74,32 @@ export const agentOutputJsonSchema = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
properties: {
|
properties: {
|
||||||
status: { const: 'question' },
|
status: { const: 'questions' },
|
||||||
question: { type: 'string' },
|
questions: {
|
||||||
options: {
|
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
label: { type: 'string' },
|
id: { type: 'string' },
|
||||||
description: { 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: {
|
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 */
|
/** 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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,9 +183,12 @@ export interface AgentWaitingEvent extends DomainEvent {
|
|||||||
name: string;
|
name: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
question: string;
|
questions: Array<{
|
||||||
options?: Array<{ label: string; description?: string }>;
|
id: string;
|
||||||
multiSelect?: boolean;
|
question: string;
|
||||||
|
options?: Array<{ label: string; description?: string }>;
|
||||||
|
multiSelect?: boolean;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)}`, [
|
||||||
{ label: 'PostgreSQL', description: 'Relational, ACID compliant' },
|
{
|
||||||
{ label: 'SQLite', description: 'Lightweight, file-based' },
|
id: 'q1',
|
||||||
|
question: 'Which database should I use?',
|
||||||
|
options: [
|
||||||
|
{ label: 'PostgreSQL', description: 'Relational, ACID compliant' },
|
||||||
|
{ 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)}`, [
|
||||||
{ label: 'React' },
|
{
|
||||||
{ label: 'Vue' },
|
id: 'q1',
|
||||||
{ label: 'Svelte' },
|
question: 'Select your framework',
|
||||||
|
options: [
|
||||||
|
{ label: 'React' },
|
||||||
|
{ label: 'Vue' },
|
||||||
|
{ 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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user