Skip to content
Merged
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: 3 additions & 3 deletions scripts/tui-host.no-jest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import type { Harness, Sequence } from '@lib/constants';
import { buildSession } from '@lib/wizard-session';
import { initLocalDev } from '@lib/local-dev';
import { configureGatewayFromCIEnvironment } from '@lib/gateway-session';
import { runAgent } from '@lib/agent/agent-runner';
import { runProgramAgent } from '@lib/programs/run-agent-legacy';
import { TaskStreamPush, createFileDestination } from '@lib/task-stream/index';
import { getAuditChecks } from '@lib/programs/audit/types';
import { authenticate } from '@lib/agent/runner/shared/authenticate';
Expand Down Expand Up @@ -334,13 +334,13 @@ async function main() {
await step.run(await runSessionFor(step));
store.completeRunStep(step.id);
} else if (step.screenId === 'run') {
await runAgent(programConfig, await runSessionFor(step));
await runProgramAgent(programConfig, await runSessionFor(step));
} else if (step.isComplete) {
await store.waitUntil(step.isComplete);
}
}
} else {
await runAgent(programConfig, store.session);
await runProgramAgent(programConfig, store.session);
}
};

Expand Down
8 changes: 7 additions & 1 deletion src/__tests__/architecture/known-violations.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
"src/lib/programs/migration/index.ts -> src/lib/programs/migration/content/index.tsx",
"src/lib/programs/posthog-integration/index.ts -> src/lib/agent/agent-interface.ts",
"src/lib/programs/posthog-integration/index.ts -> src/lib/agent/agent-runner.ts",
"src/lib/programs/posthog-integration/index.ts -> src/lib/agent/runner/shared/bootstrap.ts",
"src/lib/programs/posthog-integration/index.ts -> src/lib/programs/posthog-integration/content/index.tsx",
"src/lib/programs/program-registry.ts -> src/lib/programs/agent-skill/content/index.tsx",
"src/lib/programs/program-step.ts -> src/lib/agent/agent-runner.ts",
Expand All @@ -37,6 +36,12 @@
"src/lib/programs/replay-vision/index.ts -> src/lib/agent/agent-runner.ts",
"src/lib/programs/revenue-analytics/detect.ts -> src/lib/agent/agent-runner.ts",
"src/lib/programs/revenue-analytics/index.ts -> src/lib/programs/revenue-analytics/content/index.tsx",
"src/lib/programs/run-agent-legacy.ts -> src/lib/agent/agent-interface.ts",
"src/lib/programs/run-agent-legacy.ts -> src/lib/agent/claude-settings.ts",
"src/lib/programs/run-agent-legacy.ts -> src/lib/agent/runner/index.ts",
"src/lib/programs/run-agent-legacy.ts -> src/lib/agent/runner/shared/authenticate.ts",
"src/lib/programs/run-agent-legacy.ts -> src/lib/agent/runner/switchboard/index.ts",
"src/lib/programs/run-agent-legacy.ts -> src/lib/yara-hooks.ts",
"src/lib/programs/self-driving/detect.ts -> src/lib/agent/agent-runner.ts",
"src/lib/programs/self-driving/index.ts -> src/lib/agent/agent-runner.ts",
"src/lib/programs/self-driving/index.ts -> src/lib/programs/self-driving/content/index.tsx",
Expand All @@ -57,6 +62,7 @@
"src/steps/add-or-update-environment-variables.ts -> src/telemetry.ts",
"src/steps/run-prettier.ts -> src/telemetry.ts",
"src/steps/upload-environment-variables/index.ts -> src/telemetry.ts",
"src/ui/agent-progress.ts -> src/lib/agent/progress.ts",
"src/ui/index.ts -> src/ui/logging-ui.ts",
"src/ui/logging-ui.ts -> src/lib/agent/claude-settings.ts",
"src/ui/tui/components/PhaseVisuals.tsx -> src/lib/agent/agent-phase.ts",
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ vi.mock('../utils/wizard-abort', async (importOriginal) => ({
...(await importOriginal<typeof import('../utils/wizard-abort')>()),
wizardAbort: vi.fn(),
}));
vi.mock('../lib/agent/agent-runner', () => ({
runAgent: vi.fn().mockResolvedValue(undefined),
vi.mock('../lib/programs/run-agent-legacy', () => ({
runProgramAgent: vi.fn().mockResolvedValue(undefined),
}));

describe('CLI argument parsing', () => {
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/provision-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ vi.mock('../utils/wizard-abort', async (importOriginal) => ({
...(await importOriginal<typeof import('../utils/wizard-abort')>()),
wizardAbort: vi.fn(),
}));
vi.mock('../lib/agent/agent-runner', () => ({
runAgent: vi.fn().mockResolvedValue(undefined),
vi.mock('../lib/programs/run-agent-legacy', () => ({
runProgramAgent: vi.fn().mockResolvedValue(undefined),
}));

import { provisionCommand } from '../commands/provision';
Expand Down
33 changes: 16 additions & 17 deletions src/lib/__tests__/agent-interface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
import { AgentOutputSignals } from '@lib/agent/output-signals';
import { RESUME_INSTRUCTION } from '@lib/agent/signals';
import { analytics } from '@utils/analytics';
import { wizardAbort } from '@utils/wizard-abort';
import { Sequence } from '@lib/constants';
import type { WizardRunOptions } from '@utils/types';
import type { SpinnerHandle } from '@ui';
Expand All @@ -25,11 +24,6 @@ import {
// Mock dependencies
vi.mock('../../utils/analytics');
vi.mock('../../utils/debug');
// wizardAbort exits the process; the 401 tests below need it to just reject.
vi.mock('@utils/wizard-abort', async (importOriginal) => ({
...(await importOriginal<typeof import('@utils/wizard-abort')>()),
wizardAbort: vi.fn(),
}));

// Mock the SDK module
const mockQuery = vi.fn();
Expand Down Expand Up @@ -739,6 +733,10 @@ describe('subprocess gateway credentials', () => {

describe('gateway re-mint on 401', () => {
const spinner = { start: vi.fn(), stop: vi.fn(), message: vi.fn() };
// Where the run reports the auth screen; stands where getUI() used to.
const emit = vi.fn();
const authErrors = () =>
emit.mock.calls.filter(([e]) => e.kind === 'authError');
const options: WizardRunOptions = {
debug: false,
installDir: '/test/dir',
Expand Down Expand Up @@ -766,6 +764,7 @@ describe('gateway re-mint on 401', () => {
triageProvider: () => Promise.resolve('false_positive'),
gatewayAuth,
refreshGatewayAuth,
emit,
});
const run = (cfg: ReturnType<typeof config>) =>
runAgent(cfg, 'test prompt', options, spinner as unknown as SpinnerHandle, {
Expand Down Expand Up @@ -822,7 +821,7 @@ describe('gateway re-mint on 401', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUIInstance.spinner.mockReturnValue(spinner);
vi.mocked(wizardAbort).mockRejectedValue(new Error('wizardAbort: exit'));
emit.mockReset();
});

it('mints once and resumes the session when an aged bearer is rejected', async () => {
Expand All @@ -838,7 +837,7 @@ describe('gateway re-mint on 401', () => {

expect(result).toEqual({});
expect(refresh).toHaveBeenCalledTimes(1);
expect(wizardAbort).not.toHaveBeenCalled();
expect(authErrors()).toHaveLength(0);
expect(mockQuery).toHaveBeenCalledTimes(2);
const [first, second] = mockQuery.mock.calls.map((c) => c[0]);
expect(first.options.resume).toBeUndefined();
Expand Down Expand Up @@ -877,11 +876,12 @@ describe('gateway re-mint on 401', () => {

expect(refresh).toHaveBeenCalledTimes(1);
expect(mockQuery).toHaveBeenCalledTimes(2);
expect(mockUIInstance.showAuthError).toHaveBeenCalledTimes(1);
expect(wizardAbort).toHaveBeenCalledTimes(1);
// In production wizardAbort exits; the mocked rejection surfaces as the
// run's API error.
expect(result.error).toBe('WIZARD_API_ERROR');
expect(authErrors()).toHaveLength(1);
// The auth screen is reported, and the decided failure goes back to the
// caller, which owns the exit.
expect(result.error).toBeUndefined();
expect(result.failure?.message).toBe('Authentication failed (401)');
expect(result.failure?.code).toBeDefined();
});

it('judges a failed resumed session on its own error, not the old 401', async () => {
Expand Down Expand Up @@ -918,7 +918,7 @@ describe('gateway re-mint on 401', () => {
expect(result.error).toBe('WIZARD_API_ERROR');
expect(result.message).toContain('500');
expect(result.message).not.toContain('401');
expect(mockUIInstance.showAuthError).not.toHaveBeenCalled();
expect(authErrors()).toHaveLength(0);
});

it('does not re-mint when a fresh bearer is rejected', async () => {
Expand All @@ -932,9 +932,8 @@ describe('gateway re-mint on 401', () => {
// A fresh token the gateway rejects is a bad credential, not age.
expect(refresh).not.toHaveBeenCalled();
expect(mockQuery).toHaveBeenCalledTimes(1);
expect(mockUIInstance.showAuthError).toHaveBeenCalledTimes(1);
expect(wizardAbort).toHaveBeenCalledTimes(1);
expect(result.error).toBe('WIZARD_API_ERROR');
expect(authErrors()).toHaveLength(1);
expect(result.failure?.message).toBe('Authentication failed (401)');
});
});

Expand Down
55 changes: 45 additions & 10 deletions src/lib/__tests__/wizard-ask-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,15 +212,17 @@ describe('createWizardAskBridge', () => {
});

describe('timeout', () => {
it('resolves every field with the cancelled sentinel and dismisses the host overlay when the user does not answer in time', async () => {
it('resolves every field with the cancelled sentinel and aborts the question when the user does not answer in time', async () => {
vi.useFakeTimers();
try {
// showQuestion intentionally never resolves — the timeout has to win.
const cancelQuestion = vi.fn();
const signals: AbortSignal[] = [];
const bridge = createWizardAskBridge({
getSource: () => 'product-tours',
showQuestion: () => new Promise<AskAnswers>(() => undefined),
cancelQuestion,
showQuestion: (_question, { signal }) => {
signals.push(signal);
return new Promise<AskAnswers>(() => undefined);
},
timeoutMs: 1000,
});

Expand All @@ -230,6 +232,7 @@ describe('createWizardAskBridge', () => {
{ id: 'audience', prompt: 'Who?', kind: 'text' },
],
});
expect(signals[0].aborted).toBe(false);

vi.advanceTimersByTime(1000);

Expand All @@ -244,7 +247,7 @@ describe('createWizardAskBridge', () => {
// Without this, the host's pending-question state survives the
// timeout and every later wizard_ask in the run is rejected as a
// duplicate request.
expect(cancelQuestion).toHaveBeenCalledTimes(1);
expect(signals[0].aborted).toBe(true);

const cancelledCall = wizardCaptureMock.mock.calls.find(
([name]) => name === 'wizard_ask cancelled',
Expand All @@ -255,14 +258,16 @@ describe('createWizardAskBridge', () => {
}
});

it('does not dismiss the overlay when the user answers before the timeout', async () => {
it('does not abort the question when the user answers before the timeout', async () => {
vi.useFakeTimers();
try {
const cancelQuestion = vi.fn();
const signals: AbortSignal[] = [];
const bridge = createWizardAskBridge({
getSource: () => 'product-tours',
showQuestion: () => Promise.resolve({ goal: 'ship it' }),
cancelQuestion,
showQuestion: (_question, { signal }) => {
signals.push(signal);
return Promise.resolve({ goal: 'ship it' });
},
timeoutMs: 1000,
});

Expand All @@ -271,7 +276,37 @@ describe('createWizardAskBridge', () => {
});

vi.advanceTimersByTime(1000);
expect(cancelQuestion).not.toHaveBeenCalled();
expect(signals[0].aborted).toBe(false);
} finally {
vi.useRealTimers();
}
});

it('aborts only the question whose timeout fired', async () => {
vi.useFakeTimers();
try {
const signals: AbortSignal[] = [];
const bridge = createWizardAskBridge({
getSource: () => 'product-tours',
showQuestion: (_question, { signal }) => {
signals.push(signal);
return new Promise<AskAnswers>(() => undefined);
},
timeoutMs: 1000,
});
const questions = [
{ id: 'goal', prompt: 'Goal?', kind: 'text' as const },
];

const first = bridge.request({ questions });
vi.advanceTimersByTime(500);
void bridge.request({ questions });
vi.advanceTimersByTime(500);

await expect(first).resolves.toMatchObject({ timedOut: true });
// The second question is still open: the first one's timeout must not
// dismiss it.
expect(signals.map((signal) => signal.aborted)).toEqual([true, false]);
} finally {
vi.useRealTimers();
}
Expand Down
39 changes: 39 additions & 0 deletions src/lib/agent/__tests__/progress-collector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { OutroKind } from '@lib/wizard-session';
import { createProgressCollector } from '../runner/shared/progress-collector';
import { MAX_STATUS_MESSAGES } from '@lib/status-history';

it('retains a bounded FIFO of statuses and drops consecutive duplicates', () => {
const observer = vi.fn();
const collector = createProgressCollector(observer);
for (let i = 0; i < MAX_STATUS_MESSAGES + 3; i++) {
collector.emit({ kind: 'status', message: `status ${i}` });
collector.emit({ kind: 'status', message: `status ${i}` });
}
const statuses = collector.snapshot().statusMessages;
expect(statuses).toHaveLength(MAX_STATUS_MESSAGES);
expect(statuses[0]).toBe('status 3');
expect(statuses.at(-1)).toBe(`status ${MAX_STATUS_MESSAGES + 2}`);
expect(observer).toHaveBeenCalledTimes((MAX_STATUS_MESSAGES + 3) * 2);
statuses.push('external mutation');
expect(collector.snapshot().statusMessages).toHaveLength(MAX_STATUS_MESSAGES);
});

it('isolates nested completion data from a mutating observer', () => {
const outro = {
kind: OutroKind.Success,
message: 'Done',
nextSteps: { heading: 'Next', items: ['Keep the report'] },
};
const collector = createProgressCollector((event) => {
if (event.kind === 'completion') {
event.outro.message = 'corrupted';
event.outro.nextSteps?.items.push('corrupted');
}
});
collector.emit({ kind: 'completion', outro });
expect(outro).toEqual({
kind: OutroKind.Success,
message: 'Done',
nextSteps: { heading: 'Next', items: ['Keep the report'] },
});
});
Loading
Loading