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
88 changes: 88 additions & 0 deletions src/lib/programs/__tests__/self-driving-detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ import {
import {
detectPostHogPresent,
POSTHOG_MANIFESTS,
SELF_DRIVING_DETECTED_TOOLS_KEY,
SELF_DRIVING_TOOL_KINDS,
getSelfDrivingDetectedTools,
} from '@lib/programs/self-driving/detect';
import { getDetectedWarehouseSources } from '@lib/programs/warehouse-source/detect';
import { WizardStore } from '@ui/tui/store';
import { SOURCE_DETECTORS } from '@lib/warehouse-sources/registry';
import type { DetectedSource } from '@lib/warehouse-sources/types';
import { toIntegrationReport } from '@lib/programs/self-driving/detect-agentic';
import {
PROJECT_MANIFESTS,
Expand Down Expand Up @@ -59,6 +66,87 @@ describe('detectSelfDrivingPrerequisites', () => {

expect(ctx.detectError).toBeUndefined();
});

/** Kinds stashed for the connected-tools ask after a scan of `tmpDir`. */
const detectedKinds = (deps: Record<string, string>): string[] => {
fs.writeFileSync(
path.join(tmpDir, 'package.json'),
JSON.stringify({ dependencies: deps }),
);
detectSelfDrivingPrerequisites(
buildSession({ installDir: tmpDir }),
setCtx,
);
const tools = ctx[SELF_DRIVING_DETECTED_TOOLS_KEY] as
| DetectedSource[]
| undefined;
return (tools ?? []).map((s) => s.kind);
};

it('stashes tools detected in the codebase for the connected-tools ask', () => {
expect(detectedKinds({ '@sentry/node': '^7.0.0' })).toContain('Sentry');
});

it('keeps only the tools the inbox can connect', () => {
// `pg` and `stripe` are warehouse sources, not connected tools.
expect(detectedKinds({ pg: '^8.0.0', stripe: '^14.0.0' })).toEqual([]);
expect(detectedKinds({ pg: '^8.0.0', '@sentry/node': '^7.0.0' })).toEqual([
'Sentry',
]);
});

it('writes nothing when the codebase has no detectable tools', () => {
// Bare dir: valid, but no tools to prioritise, so the key stays unset.
const session = buildSession({ installDir: tmpDir });
detectSelfDrivingPrerequisites(session, setCtx);

expect(ctx.detectError).toBeUndefined();
expect(ctx[SELF_DRIVING_DETECTED_TOOLS_KEY]).toBeUndefined();
});
});

describe('SELF_DRIVING_TOOL_KINDS', () => {
it('names only kinds the source registry can actually detect', () => {
// A plain string set, so a registry rename would otherwise drop a tool silently.
const known = new Set(SOURCE_DETECTORS.map((d) => d.kind));
expect([...SELF_DRIVING_TOOL_KINDS].filter((k) => !known.has(k))).toEqual(
[],
);
});
});

describe('the detect step does not leak into the composed integration run', () => {
// Through the real store — the leak lived in the plumbing, not in detectConnectedTools.
let tmpDir: string;

beforeEach(() => {
tmpDir = makeTmpDir();
fs.writeFileSync(
path.join(tmpDir, 'package.json'),
JSON.stringify({
dependencies: { '@sentry/node': '^7.0.0', pg: '^8.0.0' },
}),
);
});
afterEach(() => cleanup(tmpDir));

it('stashes under its own key and leaves the warehouse key untouched', async () => {
const store = new WizardStore('self-driving');
store.session = buildSession({ installDir: tmpDir });
await store.runReadyHooks();

// Self-driving sees its tools...
expect(
getSelfDrivingDetectedTools(store.session).map((s) => s.kind),
).toContain('Sentry');
// ...and the integration program, on the session it inherits, sees nothing.
expect(getDetectedWarehouseSources(store.session)).toEqual([]);
const inherited = {
...store.session,
frameworkContext: { ...store.session.frameworkContext },
};
expect(getDetectedWarehouseSources(inherited)).toEqual([]);
});
});

describe('SELF_DRIVING_ABORT_CASES', () => {
Expand Down
56 changes: 56 additions & 0 deletions src/lib/programs/__tests__/self-driving-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import { buildSelfDrivingPrompt } from '@lib/programs/self-driving/prompt';
import type { PromptContext } from '@lib/agent/agent-runner';
import { HostResolution } from '@lib/host-resolution';
import type { DetectedSource } from '@lib/warehouse-sources/types';

const ctx: PromptContext = {
projectId: 123,
projectApiKey: 'phc_test',
host: HostResolution.fromApiHost('https://us.posthog.com'),
};

const SENTRY: DetectedSource = {
kind: 'Sentry',
label: 'Sentry',
mode: 'in-cli',
matchedSignal: 'found `@sentry/node` in package.json',
};
const LINEAR: DetectedSource = {
kind: 'Linear',
label: 'Linear',
mode: 'deep-link',
matchedSignal: 'found `LINEAR_API_KEY` in .env',
};

describe('buildSelfDrivingPrompt', () => {
it('covers only the Self-driving steps — integration is a separate phase', () => {
const prompt = buildSelfDrivingPrompt(ctx);
Expand All @@ -34,3 +48,45 @@ describe('buildSelfDrivingPrompt', () => {
expect(prompt).toContain('STEP 7 — Write the report and hand off');
});
});

describe('detected-tools block', () => {
it('lists each detected tool with its source_type and matched signal', () => {
const prompt = buildSelfDrivingPrompt(ctx, [SENTRY, LINEAR]);
expect(prompt).toContain('Tools detected in this codebase');
expect(prompt).toContain('Sentry (source_type: Sentry)');
expect(prompt).toContain('found `@sentry/node` in package.json');
expect(prompt).toContain('Linear (source_type: Linear)');
});

it('appears before STEP 5 so the connected-tools ask can read it', () => {
const prompt = buildSelfDrivingPrompt(ctx, [SENTRY]);
expect(prompt.indexOf('Tools detected in this codebase')).toBeLessThan(
prompt.indexOf('STEP 5 — Offer issue-tracker integrations'),
);
});

it('points STEP 5 at the detected list, basics, then others', () => {
const prompt = buildSelfDrivingPrompt(ctx, [SENTRY]);
// Scope to STEP 5's text (up to STEP 6) and ignore incidental wrapping.
const step5 = prompt
.slice(prompt.indexOf('STEP 5 —'), prompt.indexOf('STEP 6 —'))
.replace(/\s+/g, ' ');
expect(step5).toContain('Tools detected in this codebase');
expect(step5).toContain('SaaS basics');
expect(step5).toContain('others');
});

it('states nothing was found when the scan is empty (no invented scan)', () => {
const prompt = buildSelfDrivingPrompt(ctx, []);
expect(prompt).toContain('none found by the dependency + env scan');
expect(prompt).not.toContain('source_type:');
});

it('defaults to an empty scan when no sources are passed', () => {
// The single-arg call site (older tests, and the type default) must not throw.
expect(() => buildSelfDrivingPrompt(ctx)).not.toThrow();
expect(buildSelfDrivingPrompt(ctx)).toContain(
'none found by the dependency + env scan',
);
});
});
79 changes: 79 additions & 0 deletions src/lib/programs/self-driving/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ import {
type Dirent,
} from 'fs';
import { join } from 'path';
import { analytics } from '@utils/analytics';
import type { WizardSession } from '@lib/wizard-session';
import type { AbortCase } from '@lib/agent/agent-runner';
import { detectWarehouseSources } from '@lib/warehouse-sources/detect';
import type { DetectedSource } from '@lib/warehouse-sources/types';

/** frameworkContext key holding the deterministic PostHog-presence result. */
export const POSTHOG_PRESENT_KEY = 'postHogPresent';
Expand All @@ -41,6 +44,20 @@ export const POSTHOG_PRESENT_KEY = 'postHogPresent';
*/
export const SELF_DRIVING_INTEGRATE_PATH_KEY = 'selfDrivingIntegratePath';

/** Self-driving's own detected-tools key — not the warehouse one, which the integration program reads. */
export const SELF_DRIVING_DETECTED_TOOLS_KEY = 'selfDrivingDetectedTools';

/** Read the detected tools out of frameworkContext. */
export function getSelfDrivingDetectedTools(
session: WizardSession,
): DetectedSource[] {
return (
(session.frameworkContext[SELF_DRIVING_DETECTED_TOOLS_KEY] as
| DetectedSource[]
| undefined) ?? []
);
}

// Matches `posthog` at a dependency boundary (line start, or after "'/=:.@ or
// whitespace): catches `com.posthog:posthog-android` and `@posthog/ai`, skips
// substrings inside other words.
Expand Down Expand Up @@ -291,4 +308,66 @@ export function detectSelfDrivingPrerequisites(
// screen: found → skip straight to self-driving; not found → ask to set up
// PostHog first.
setFrameworkContext(POSTHOG_PRESENT_KEY, detectPostHogPresent(installDir));

detectConnectedTools(installDir, setFrameworkContext);
}

/** Step 5's ask options intersected with `SOURCE_DETECTORS` — reconcile when the skill's catalog grows. */
export const SELF_DRIVING_TOOL_KINDS: ReadonlySet<string> = new Set([
// Issue trackers / code hosts
'Github',
'GitLab',
'Gitea',
'Linear',
'Jira',
'Shortcut',
// Error tracking
'Sentry',
'Rollbar',
'Bugsnag',
'Honeybadger',
'Raygun',
// Support desks
'Zendesk',
'Freshdesk',
'Front',
'Gorgias',
'Kustomer',
'Plain',
// Security scanners
'Snyk',
// Product feedback
'Canny',
'Productboard',
// Search analytics
'GoogleSearchConsole',
]);

/** Scan for inbox-connectable tools so STEP 5 can surface them first. Best-effort — never blocks detection. */
function detectConnectedTools(
installDir: string,
setFrameworkContext: (key: string, value: unknown) => void,
): void {
try {
const tools = detectWarehouseSources(installDir).filter((s) =>
SELF_DRIVING_TOOL_KINDS.has(s.kind),
);

// Tagged even at zero, so "found nothing" is distinguishable from "never ran".
analytics.setTag('connected_tools_detected_count', tools.length);
if (tools.length === 0) return;

analytics.setTag(
'connected_tools_detected',
tools.map((s) => s.kind).join(','),
);
setFrameworkContext(SELF_DRIVING_DETECTED_TOOLS_KEY, tools);
} catch (error) {
// -1, not absent, so a failed scan stays distinguishable from one that never ran.
analytics.setTag('connected_tools_detected_count', -1);
analytics.captureException(
error instanceof Error ? error : new Error(String(error)),
{ step: 'detectConnectedTools' },
);
}
}
Loading
Loading