Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ yargs(hideBin(process.argv))
'PostHog personal API key (phx_xxx) for authentication\nenv: POSTHOG_WIZARD_API_KEY',
type: 'string',
},
interactive: {
default: false,
describe:
'Review and approve planned events before implementation\nenv: POSTHOG_WIZARD_INTERACTIVE',
type: 'boolean',
},
})
.command(
['$0'],
Expand Down
26 changes: 26 additions & 0 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe('CLI argument parsing', () => {
delete process.env.POSTHOG_WIZARD_CI;
delete process.env.POSTHOG_WIZARD_API_KEY;
delete process.env.POSTHOG_WIZARD_INSTALL_DIR;
delete process.env.POSTHOG_WIZARD_INTERACTIVE;

// Mock process.exit to prevent test runner from exiting
process.exit = jest.fn() as any;
Expand Down Expand Up @@ -254,6 +255,31 @@ describe('CLI argument parsing', () => {
});
});

describe('--interactive flag', () => {
test('defaults to false when not specified', async () => {
await runCLI([]);

const args = getLastCallArgs(mockRunWizard);
expect(args.interactive).toBe(false);
});

test('can be set to true', async () => {
await runCLI(['--interactive']);

const args = getLastCallArgs(mockRunWizard);
expect(args.interactive).toBe(true);
});

test('respects POSTHOG_WIZARD_INTERACTIVE env var', async () => {
process.env.POSTHOG_WIZARD_INTERACTIVE = 'true';

await runCLI([]);

const args = getLastCallArgs(mockRunWizard);
expect(args.interactive).toBe(true);
});
});

describe('CI environment variables', () => {
test('respects POSTHOG_WIZARD_CI', async () => {
process.env.POSTHOG_WIZARD_CI = 'true';
Expand Down
289 changes: 289 additions & 0 deletions src/lib/__tests__/agent-interface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe('runAgent', () => {
signup: false,
localMcp: false,
ci: false,
interactive: false,
menu: false,
};

Expand Down Expand Up @@ -251,4 +252,292 @@ describe('runAgent', () => {
expect(mockClack.log.error).not.toHaveBeenCalled();
});
});

describe('interactive approval flow', () => {
it('should trigger onApprovalNeeded callback when approval signal is detected', async () => {
const mockOnApproval = jest.fn().mockResolvedValue({
approved: true,
feedback: 'The user approved the plan. Proceed with implementation.',
});

function* mockGeneratorWithApproval() {
yield {
type: 'system',
subtype: 'init',
model: 'claude-opus-4-5-20251101',
tools: [],
mcp_servers: [],
};

yield {
type: 'assistant',
message: {
content: [
{
type: 'text',
text: '[WIZARD-APPROVAL-NEEDED]\n1. page_view | src/app.tsx:15 | Fires on route change\n[/WIZARD-APPROVAL-NEEDED]',
},
],
},
};

yield {
type: 'result',
subtype: 'success',
is_error: false,
result: 'Agent completed successfully',
};
}

mockQuery.mockReturnValue(mockGeneratorWithApproval());

const result = await runAgent(
defaultAgentConfig,
'test prompt',
defaultOptions,
mockSpinner as unknown as ReturnType<typeof clack.spinner>,
{
successMessage: 'Test success',
errorMessage: 'Test error',
onApprovalNeeded: mockOnApproval,
},
);

expect(mockOnApproval).toHaveBeenCalledWith(
'1. page_view | src/app.tsx:15 | Fires on route change',
);
expect(result).toEqual({});
});

it('should skip approval when no callback is provided', async () => {
function* mockGeneratorWithApprovalSignalButNoCallback() {
yield {
type: 'system',
subtype: 'init',
model: 'claude-opus-4-5-20251101',
tools: [],
mcp_servers: [],
};

yield {
type: 'assistant',
message: {
content: [
{
type: 'text',
text: '[WIZARD-APPROVAL-NEEDED]\n1. page_view | src/app.tsx:15 | Fires on route change\n[/WIZARD-APPROVAL-NEEDED]',
},
],
},
};

yield {
type: 'result',
subtype: 'success',
is_error: false,
result: 'Agent completed successfully',
};
}

mockQuery.mockReturnValue(mockGeneratorWithApprovalSignalButNoCallback());

// No onApprovalNeeded callback — should complete without blocking
const result = await runAgent(
defaultAgentConfig,
'test prompt',
defaultOptions,
mockSpinner as unknown as ReturnType<typeof clack.spinner>,
{
successMessage: 'Test success',
errorMessage: 'Test error',
},
);

expect(result).toEqual({});
expect(mockSpinner.stop).toHaveBeenCalledWith('Test success');
});

it('should handle approval callback errors gracefully', async () => {
const mockOnApproval = jest
.fn()
.mockRejectedValue(new Error('User cancelled'));

function* mockGeneratorWithApprovalError() {
yield {
type: 'system',
subtype: 'init',
model: 'claude-opus-4-5-20251101',
tools: [],
mcp_servers: [],
};

yield {
type: 'assistant',
message: {
content: [
{
type: 'text',
text: '[WIZARD-APPROVAL-NEEDED]\n1. page_view | src/app.tsx:15 | Fires on route change\n[/WIZARD-APPROVAL-NEEDED]',
},
],
},
};

yield {
type: 'result',
subtype: 'success',
is_error: false,
result: 'Agent completed successfully',
};
}

mockQuery.mockReturnValue(mockGeneratorWithApprovalError());

const result = await runAgent(
defaultAgentConfig,
'test prompt',
defaultOptions,
mockSpinner as unknown as ReturnType<typeof clack.spinner>,
{
successMessage: 'Test success',
errorMessage: 'Test error',
onApprovalNeeded: mockOnApproval,
},
);

// Should still complete — error handler sends fallback message
expect(result).toEqual({});
});

it('should support multiple rounds of approval (modify then approve)', async () => {
let callCount = 0;
const mockOnApproval = jest.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve({
approved: false,
feedback:
'The user wants to modify: remove event_2. Output updated plan.',
});
}
return Promise.resolve({
approved: true,
feedback: 'The user approved the plan. Proceed with implementation.',
});
});

function* mockGeneratorWithTwoApprovalRounds() {
yield {
type: 'system',
subtype: 'init',
model: 'claude-opus-4-5-20251101',
tools: [],
mcp_servers: [],
};

// First plan
yield {
type: 'assistant',
message: {
content: [
{
type: 'text',
text: '[WIZARD-APPROVAL-NEEDED]\n1. event_1 | src/a.tsx:10 | desc1\n2. event_2 | src/b.tsx:20 | desc2\n[/WIZARD-APPROVAL-NEEDED]',
},
],
},
};

// Revised plan after modification
yield {
type: 'assistant',
message: {
content: [
{
type: 'text',
text: '[WIZARD-APPROVAL-NEEDED]\n1. event_1 | src/a.tsx:10 | desc1\n[/WIZARD-APPROVAL-NEEDED]',
},
],
},
};

yield {
type: 'result',
subtype: 'success',
is_error: false,
result: 'Agent completed successfully',
};
}

mockQuery.mockReturnValue(mockGeneratorWithTwoApprovalRounds());

const result = await runAgent(
defaultAgentConfig,
'test prompt',
defaultOptions,
mockSpinner as unknown as ReturnType<typeof clack.spinner>,
{
successMessage: 'Test success',
errorMessage: 'Test error',
onApprovalNeeded: mockOnApproval,
},
);

expect(mockOnApproval).toHaveBeenCalledTimes(2);
expect(result).toEqual({});
});

it('should return APPROVAL_CANCELLED error when agent emits the signal', async () => {
const mockOnApproval = jest.fn().mockResolvedValue({
approved: true,
feedback: 'Approved',
});

function* mockGeneratorWithApprovalCancelled() {
yield {
type: 'system',
subtype: 'init',
model: 'claude-opus-4-5-20251101',
tools: [],
mcp_servers: [],
};

yield {
type: 'assistant',
message: {
content: [
{
type: 'text',
text: '[ERROR-APPROVAL-CANCELLED] Approval cancelled by user or error.',
},
],
},
};

yield {
type: 'result',
subtype: 'success',
is_error: false,
result: 'Agent completed',
};
}

mockQuery.mockReturnValue(mockGeneratorWithApprovalCancelled());

const result = await runAgent(
defaultAgentConfig,
'test prompt',
defaultOptions,
mockSpinner as unknown as ReturnType<typeof clack.spinner>,
{
successMessage: 'Test success',
errorMessage: 'Test error',
onApprovalNeeded: mockOnApproval,
},
);

expect(result).toEqual({ error: 'WIZARD_APPROVAL_CANCELLED' });
expect(mockSpinner.stop).toHaveBeenCalledWith('Approval flow cancelled');
});
});
});
Loading