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,
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;
}
/**

View File

@@ -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();
});
});

View File

@@ -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;
}
/**

View File

@@ -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: {

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 */
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>;
}

View File

@@ -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),
};
}

View File

@@ -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;
}>;
};
}

View File

@@ -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);

View File

@@ -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();
});
});
});

View File

@@ -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),