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