// Smoke tests for the Settings dialog. Two entry points:
//
//   - testProviderConnection: posts a tiny "Reply with only: ok" request to
//     a BYOK API endpoint and reports a categorized result.
//   - testAgentConnection: spawns a Local CLI adapter with the same prompt,
//     drives the existing stream parser through a collector sink, and treats
//     assistant text as proof that the CLI can run unless the text is an
//     explicit model-selection error.
//
// Both functions persist nothing — no project, no chat record, no
// media-config write. The intent is to give Settings a definite "your
// configuration works" answer without users having to send a real chat to
// discover that the API key, model, base URL, or CLI is broken.
//
// The streaming counterpart for chat lives in `server.ts` under the
// `/api/proxy/*/stream` routes; both paths share the base URL policy from
// contracts so Settings and daemon-side checks reject the same hosts.
import { spawn } from 'node:child_process';
import { promises as fsp } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { applyAgentLaunchEnv, getAgentDef, resolveAgentLaunch, spawnEnvForAgent, } from './agents.js';
import { createCommandInvocation } from '@open-design/platform';
import { attachAcpSession } from './acp.js';
import { attachPiRpcSession } from './pi-rpc.js';
import { createClaudeStreamHandler } from './claude-stream.js';
import { diagnoseClaudeCliFailure } from './claude-diagnostics.js';
import { createCopilotStreamHandler } from './copilot-stream.js';
import { createJsonEventStreamHandler } from './json-event-stream.js';
import { agentCliEnvForAgent, validateAgentCliEnv } from './app-config.js';
import { classifyAgentAuthFailure, cursorAuthGuidance, probeAgentAuthStatus, } from './runtimes/auth.js';
import { isLoopbackApiHost, validateBaseUrl, } from '@open-design/contracts/api/connectionTest';
export { validateBaseUrl } from '@open-design/contracts/api/connectionTest';
// Aggressive but not punitive — happy paths usually return in under 2 s.
// Override with OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS for slow networks
// or distant providers; invalid values fall back to the default.
const DEFAULT_PROVIDER_TIMEOUT_MS = 12_000;
// CLI boot time is dominated by adapter auth/session restore; the heavy
// adapters (Codex, Cursor Agent) regularly take 5–10 s on a cold first
// run, so 45 s leaves headroom without making a hung child invisible.
// Override with OD_CONNECTION_TEST_AGENT_TIMEOUT_MS.
const DEFAULT_AGENT_TIMEOUT_MS = 45_000;
// Node's `setTimeout` silently clamps any delay above this to ~1 ms
// (with a TimeoutOverflowWarning), so an override meant to *extend*
// the budget — e.g. `OD_CONNECTION_TEST_AGENT_TIMEOUT_MS=3000000000` —
// would actually make every connection test fail almost immediately.
// Reject above the cap so the safety timeout cannot be accidentally
// disarmed by an oversized env value.
const MAX_CONNECTION_TEST_TIMEOUT_MS = 2_147_483_647;
export function resolveConnectionTestTimeoutMs(key, fallback, env = process.env) {
    const raw = env[key];
    if (raw === undefined || raw === '')
        return fallback;
    const n = Number(raw);
    if (!Number.isSafeInteger(n) || n < 1 || n > MAX_CONNECTION_TEST_TIMEOUT_MS) {
        console.warn(`connection-test: ignoring ${key}=${JSON.stringify(raw)} (must be a positive integer between 1 and ${MAX_CONNECTION_TEST_TIMEOUT_MS} ms); using ${fallback}ms`);
        return fallback;
    }
    return n;
}
function providerTimeoutMs() {
    return resolveConnectionTestTimeoutMs('OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS', DEFAULT_PROVIDER_TIMEOUT_MS);
}
function agentTimeoutMs() {
    return resolveConnectionTestTimeoutMs('OD_CONNECTION_TEST_AGENT_TIMEOUT_MS', DEFAULT_AGENT_TIMEOUT_MS);
}
const AGENT_COMPLETION_DEBOUNCE_MS = 500;
const AGENT_KILL_GRACE_MS = 2_000;
// Truncates the assistant reply we surface in the success copy so a
// chatty model can't dump kilobytes into the inline status node.
const SAMPLE_MAX_CHARS = 120;
// Generation budget for the smoke prompt. Keep this small, but not tiny:
// reasoning models can spend the first few dozen tokens in hidden reasoning
// before producing a visible `ok`.
const PROVIDER_MAX_TOKENS = 100;
const SMOKE_PROMPT = 'Reply with only: ok';
function codexExecutableGuidance(agentId, configuredOverridePath, pathResolvedPath) {
    if (agentId !== 'codex' ||
        !configuredOverridePath ||
        !pathResolvedPath ||
        configuredOverridePath === pathResolvedPath) {
        return '';
    }
    return ` Configured Codex path failed: ${configuredOverridePath}. Open Design also detected a PATH Codex CLI at ${pathResolvedPath}. Update CODEX_BIN or clear the custom path to use the detected binary.`;
}
function codexExecutableFallbackSuccessDetail(configuredOverridePath, pathResolvedPath) {
    return `Configured Codex path failed: ${configuredOverridePath}. This test succeeded with the PATH Codex CLI at ${pathResolvedPath}. Update CODEX_BIN or clear the custom path to use the detected binary.`;
}
function codexConfiguredPathSuccessDetail(configuredOverridePath) {
    return `This test used the configured Codex path: ${configuredOverridePath}.`;
}
function codexInvalidConfiguredPathFallbackDetail(configuredValue, pathResolvedPath) {
    return `Configured Codex path is invalid or not executable: ${configuredValue}. This test used the PATH Codex CLI at ${pathResolvedPath}. Update CODEX_BIN or clear the custom path to use the detected binary.`;
}
function stripCodexBinOverride(prefs) {
    if (!prefs?.codex?.CODEX_BIN)
        return prefs;
    const nextCodex = { ...prefs.codex };
    delete nextCodex.CODEX_BIN;
    const next = {
        ...prefs,
        codex: nextCodex,
    };
    if (Object.keys(nextCodex).length === 0)
        delete next.codex;
    return Object.keys(next).length > 0 ? next : undefined;
}
// Catches `Bearer …`, `x-api-key`/`api-key`/`x-goog-api-key` headers, and
// `?key=…` query strings. The provider helpers all funnel error text
// through this before logging; if a vendor surfaces the key in body text
// (some do for 401s), it stays out of the daemon log too.
function escapeRegExp(value) {
    return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function redactSecrets(text, exactSecrets = []) {
    if (typeof text !== 'string' || text.length === 0)
        return '';
    let redacted = text
        .replace(/Bearer\s+[A-Za-z0-9_\-.+/=]+/gi, 'Bearer [REDACTED]')
        .replace(/(x-api-key|api-key|x-goog-api-key)\s*[:=]\s*[^\s,;"']+/gi, '$1: [REDACTED]')
        .replace(/([?&]key=)[^&\s]+/gi, '$1[REDACTED]');
    for (const secret of exactSecrets) {
        if (typeof secret !== 'string' || secret.length === 0)
            continue;
        redacted = redacted.replace(new RegExp(escapeRegExp(secret), 'g'), '[REDACTED]');
    }
    return redacted;
}
function appendVersionedApiPath(baseUrl, suffix) {
    const url = new URL(baseUrl);
    const pathname = url.pathname.replace(/\/+$/, '');
    url.pathname = /\/v\d+(\/|$)/.test(pathname)
        ? `${pathname}${suffix}`
        : `${pathname}/v1${suffix}`;
    return url.toString();
}
function truncateSample(text) {
    if (typeof text !== 'string')
        return '';
    const trimmed = text.replace(/\s+/g, ' ').trim();
    if (trimmed.length <= SAMPLE_MAX_CHARS)
        return trimmed;
    return `${trimmed.slice(0, SAMPLE_MAX_CHARS - 1)}…`;
}
export function isSmokeOkReply(text) {
    return typeof text === 'string' && text.trim().toLowerCase() === 'ok';
}
function isLikelyModelErrorText(text) {
    return (/model/i.test(text) &&
        /(not found|not exist|does not exist|unknown|invalid|unsupported|not supported|not have access|no access|issue with the selected model)/i.test(text));
}
function smokeFailureDetail(sample) {
    return sample
        ? `Expected smoke test reply "ok"; got "${sample}"`
        : 'Provider returned a 2xx response without assistant text';
}
function inspectProviderCompletion(protocol, data, requestedModel, enforceResponseModel) {
    const obj = data && typeof data === 'object' ? data : null;
    if (!obj)
        return { valid: false };
    if (protocol === 'openai' || protocol === 'azure') {
        const responseModel = typeof obj.model === 'string' ? obj.model : '';
        if (protocol === 'openai' &&
            enforceResponseModel &&
            responseModel &&
            requestedModel &&
            responseModel !== requestedModel) {
            return {
                valid: false,
                kind: 'not_found_model',
                detail: `Provider responded with model "${responseModel}" instead of requested "${requestedModel}".`,
            };
        }
        const choices = obj.choices;
        if (!Array.isArray(choices) || choices.length === 0)
            return { valid: false };
        const first = choices[0];
        const finishReason = typeof first?.finish_reason === 'string' ? first.finish_reason : '';
        return {
            valid: true,
            sample: finishReason
                ? `valid completion (${finishReason})`
                : 'valid completion',
        };
    }
    if (protocol === 'anthropic') {
        return {
            valid: Array.isArray(obj.content) ||
                typeof obj.stop_reason === 'string',
            sample: 'valid completion',
        };
    }
    if (protocol === 'google') {
        return {
            valid: Array.isArray(obj.candidates),
            sample: 'valid completion',
        };
    }
    if (protocol === 'ollama') {
        const msg = obj.message;
        const hasContent = typeof msg?.content === 'string';
        return {
            valid: Array.isArray(obj.messages) || hasContent,
            ...(hasContent ? { sample: truncateSample(msg?.content) } : {}),
        };
    }
    return { valid: false };
}
function statusToKind(status, detailText = '') {
    if (status === 401)
        return 'auth_failed';
    if (status === 403)
        return 'forbidden';
    if (status === 404) {
        return isLikelyModelErrorText(detailText)
            ? 'not_found_model'
            : 'invalid_base_url';
    }
    if (status === 429)
        return 'rate_limited';
    if (status >= 500)
        return 'upstream_unavailable';
    return 'unknown';
}
function extractOpenAiModelIds(data) {
    const items = data.data;
    if (!Array.isArray(items))
        return [];
    return items
        .map((item) => item?.id)
        .filter((id) => typeof id === 'string' && id.length > 0);
}
function extractProviderErrorDetail(data, rawText) {
    const obj = data && typeof data === 'object' ? data : null;
    const error = obj ? obj.error : null;
    if (typeof error === 'string')
        return error;
    if (error && typeof error === 'object') {
        const message = error.message;
        if (typeof message === 'string' && message.trim())
            return message;
    }
    const message = obj ? obj.message : null;
    if (typeof message === 'string' && message.trim())
        return message;
    return rawText.trim().slice(0, 240);
}
function networkErrorToKind(err) {
    if (err instanceof Error) {
        if (err.name === 'AbortError')
            return 'timeout';
        // fetch's TypeError surface for DNS/TLS/connect failures is
        // `TypeError` with a `cause` whose `code` is one of these.
        const cause = err.cause;
        const code = cause?.code;
        if (code === 'ENOTFOUND' ||
            code === 'EAI_AGAIN' ||
            code === 'ECONNREFUSED' ||
            code === 'ECONNRESET' ||
            code === 'ETIMEDOUT' ||
            code === 'EHOSTUNREACH' ||
            code === 'ENETUNREACH' ||
            code === 'CERT_HAS_EXPIRED' ||
            code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
            return 'invalid_base_url';
        }
    }
    return 'unknown';
}
async function validateLocalOpenAiModel(input, parsed, signal, start) {
    if (input.protocol !== 'openai' || !isLoopbackApiHost(parsed.hostname)) {
        return null;
    }
    const url = appendVersionedApiPath(String(input.baseUrl), '/models');
    let response;
    try {
        response = await fetch(url, {
            method: 'GET',
            headers: { authorization: `Bearer ${String(input.apiKey)}` },
            signal,
            redirect: 'error',
        });
    }
    catch {
        // Local OpenAI-compatible servers vary; if model listing is unavailable,
        // fall back to the smoke completion path instead of blocking the test.
        return null;
    }
    if (!response.ok)
        return null;
    let data;
    try {
        const rawText = await response.text();
        data = rawText ? JSON.parse(rawText) : {};
    }
    catch {
        return null;
    }
    const modelIds = extractOpenAiModelIds(data);
    if (modelIds.length === 0 || modelIds.includes(input.model))
        return null;
    return {
        ok: false,
        kind: 'not_found_model',
        latencyMs: Date.now() - start,
        model: input.model,
        status: response.status,
        detail: `Model "${input.model}" is not reported by the local provider.`,
    };
}
function buildProviderCall(input) {
    const baseUrl = String(input.baseUrl);
    const apiKey = String(input.apiKey);
    const model = String(input.model);
    switch (input.protocol) {
        case 'anthropic':
            return {
                url: appendVersionedApiPath(baseUrl, '/messages'),
                headers: {
                    'content-type': 'application/json',
                    'x-api-key': apiKey,
                    'anthropic-version': '2023-06-01',
                },
                body: {
                    model,
                    max_tokens: PROVIDER_MAX_TOKENS,
                    messages: [{ role: 'user', content: SMOKE_PROMPT }],
                    stream: false,
                },
                extractText: (data) => {
                    const blocks = data.content;
                    if (!Array.isArray(blocks))
                        return '';
                    for (const block of blocks) {
                        if (block &&
                            typeof block === 'object' &&
                            block.type === 'text' &&
                            typeof block.text === 'string') {
                            return block.text;
                        }
                    }
                    return '';
                },
            };
        case 'openai':
            return {
                url: appendVersionedApiPath(baseUrl, '/chat/completions'),
                headers: {
                    'content-type': 'application/json',
                    authorization: `Bearer ${apiKey}`,
                },
                body: {
                    model,
                    max_tokens: PROVIDER_MAX_TOKENS,
                    messages: [{ role: 'user', content: SMOKE_PROMPT }],
                    stream: false,
                },
                extractText: extractOpenAIMessageText,
            };
        case 'azure': {
            const url = new URL(baseUrl);
            const basePath = url.pathname.replace(/\/+$/, '');
            const usesVersionedOpenAIPath = /\/openai\/v\d+(?:$|\/)/.test(basePath);
            const apiVersion = typeof input.apiVersion === 'string' && input.apiVersion.trim()
                ? input.apiVersion.trim()
                : usesVersionedOpenAIPath
                    ? ''
                    : '2024-10-21';
            url.pathname = usesVersionedOpenAIPath
                ? `${basePath}/chat/completions`
                : `${basePath}/openai/deployments/${encodeURIComponent(model)}/chat/completions`;
            if (usesVersionedOpenAIPath && !apiVersion) {
                url.searchParams.delete('api-version');
            }
            if (apiVersion) {
                url.searchParams.set('api-version', apiVersion);
            }
            return {
                url: url.toString(),
                headers: {
                    'content-type': 'application/json',
                    'api-key': apiKey,
                },
                body: {
                    ...(usesVersionedOpenAIPath ? { model } : {}),
                    max_tokens: PROVIDER_MAX_TOKENS,
                    messages: [{ role: 'user', content: SMOKE_PROMPT }],
                    stream: false,
                },
                extractText: extractOpenAIMessageText,
            };
        }
        case 'google': {
            const trimmedBase = baseUrl.replace(/\/+$/, '');
            return {
                url: `${trimmedBase}/v1beta/models/${encodeURIComponent(model)}:generateContent`,
                headers: {
                    'content-type': 'application/json',
                    'x-goog-api-key': apiKey,
                },
                body: {
                    contents: [
                        { role: 'user', parts: [{ text: SMOKE_PROMPT }] },
                    ],
                    generationConfig: { maxOutputTokens: PROVIDER_MAX_TOKENS },
                },
                extractText: (data) => {
                    const candidates = data.candidates;
                    if (!Array.isArray(candidates) || candidates.length === 0)
                        return '';
                    const parts = candidates[0]
                        .content?.parts;
                    if (!Array.isArray(parts))
                        return '';
                    return parts
                        .map((p) => typeof p?.text === 'string' ? p.text : '')
                        .join('');
                },
            };
        }
        case 'ollama': {
            const trimmedBase = baseUrl.replace(/\/+$/, '').replace(/\/api\/?$/, '');
            return {
                url: `${trimmedBase}/api/chat`,
                headers: {
                    'content-type': 'application/json',
                    authorization: `Bearer ${apiKey}`,
                },
                body: {
                    model,
                    messages: [{ role: 'user', content: SMOKE_PROMPT }],
                    stream: false,
                },
                extractText: (data) => {
                    const message = data.message;
                    if (message && typeof message.content === 'string') {
                        return message.content;
                    }
                    return '';
                },
            };
        }
        default:
            throw new Error(`Unknown protocol: ${input.protocol}`);
    }
}
// Sibling of the proxy's `extractOpenAIText` (which reads streaming
// `delta.content`). We need the non-streaming `message.content` shape
// here. Kept module-local so the chat path doesn't change.
function extractOpenAIMessageText(data) {
    const choices = data.choices;
    if (!Array.isArray(choices) || choices.length === 0)
        return '';
    const first = choices[0];
    if (typeof first?.message?.content === 'string')
        return first.message.content;
    if (typeof first?.text === 'string')
        return first.text;
    return '';
}
export async function testProviderConnection(input) {
    const start = Date.now();
    const model = String(input.model ?? '');
    const validated = validateBaseUrl(input.baseUrl);
    if (validated.error || !validated.parsed) {
        const kind = validated.forbidden ? 'forbidden' : 'invalid_base_url';
        return {
            ok: false,
            kind,
            latencyMs: Date.now() - start,
            model,
            detail: validated.error ?? '',
        };
    }
    let call;
    try {
        call = buildProviderCall(input);
    }
    catch (err) {
        return {
            ok: false,
            kind: 'unknown',
            latencyMs: Date.now() - start,
            model,
            detail: redactSecrets(err instanceof Error ? err.message : String(err), [
                input.apiKey,
            ]),
        };
    }
    const controller = new AbortController();
    const abortFromParent = () => controller.abort();
    if (input.signal?.aborted) {
        controller.abort();
    }
    else {
        input.signal?.addEventListener('abort', abortFromParent, { once: true });
    }
    const timer = setTimeout(() => controller.abort(), providerTimeoutMs());
    try {
        const modelError = await validateLocalOpenAiModel(input, validated.parsed, controller.signal, start);
        if (modelError)
            return modelError;
        const response = await fetch(call.url, {
            method: 'POST',
            headers: call.headers,
            body: JSON.stringify(call.body),
            signal: controller.signal,
            redirect: 'error',
        });
        const latencyMs = Date.now() - start;
        if (response.ok) {
            let data;
            let rawText = '';
            try {
                rawText = await response.text();
                data = rawText ? JSON.parse(rawText) : {};
            }
            catch (parseErr) {
                console.warn(`[test:provider] ${input.protocol} ${validated.parsed.hostname} model=${input.model} → parse failed: ${redactSecrets(rawText.slice(0, 200), [input.apiKey])}`);
                return {
                    ok: false,
                    kind: 'unknown',
                    latencyMs,
                    model,
                    status: response.status,
                    detail: redactSecrets(parseErr instanceof Error ? parseErr.message : String(parseErr), [input.apiKey]),
                };
            }
            const completion = inspectProviderCompletion(input.protocol, data, model, isLoopbackApiHost(validated.parsed.hostname));
            if (completion.kind) {
                const detail = redactSecrets(completion.detail ?? '', [input.apiKey]);
                console.warn(`[test:provider] ${input.protocol} ${validated.parsed.hostname} model=${input.model} → ${response.status} in ${latencyMs}ms (${completion.kind})${detail ? ` ${detail}` : ''}`);
                return {
                    ok: false,
                    kind: completion.kind,
                    latencyMs,
                    model,
                    status: response.status,
                    detail,
                };
            }
            const replyText = call.extractText(data);
            let rawSample = truncateSample(replyText);
            if (rawSample && isLikelyModelErrorText(rawSample)) {
                const detail = redactSecrets(smokeFailureDetail(rawSample), [input.apiKey]);
                console.warn(`[test:provider] ${input.protocol} ${validated.parsed.hostname} model=${input.model} → ${response.status} in ${latencyMs}ms (not_found_model)${detail ? ` ${detail}` : ''}`);
                return {
                    ok: false,
                    kind: 'not_found_model',
                    latencyMs,
                    model,
                    status: response.status,
                    detail,
                };
            }
            if (!rawSample && !completion.valid) {
                const detail = redactSecrets(extractProviderErrorDetail(data, rawText) ||
                    smokeFailureDetail(rawSample), [input.apiKey]);
                console.warn(`[test:provider] ${input.protocol} ${validated.parsed.hostname} model=${input.model} → ${response.status} in ${latencyMs}ms (unexpected_sample)${detail ? ` ${detail}` : ''}`);
                return {
                    ok: false,
                    kind: 'unknown',
                    latencyMs,
                    model,
                    status: response.status,
                    detail,
                };
            }
            if (!rawSample && completion.valid) {
                rawSample = truncateSample(completion.sample ?? 'valid completion');
            }
            const sample = redactSecrets(rawSample, [input.apiKey]);
            if (rawSample && !isSmokeOkReply(replyText)) {
                console.warn(`[test:provider] ${input.protocol} ${validated.parsed.hostname} model=${input.model} → ${response.status} in ${latencyMs}ms (connected_unexpected_sample) ${sample}`);
            }
            console.log(`[test:provider] ${input.protocol} ${validated.parsed.hostname} model=${input.model} → ${response.status} in ${latencyMs}ms`);
            return {
                ok: true,
                kind: 'success',
                latencyMs,
                model,
                status: response.status,
                sample,
            };
        }
        // Non-2xx: read body for redacted detail, then map status → kind.
        let detailText = '';
        try {
            detailText = await response.text();
        }
        catch {
            // Ignore — we still report the status code.
        }
        const redactedDetail = redactSecrets(detailText.slice(0, 240), [
            input.apiKey,
        ]);
        const kind = statusToKind(response.status, redactedDetail);
        const detail = redactedDetail ||
            (response.status === 404
                ? 'HTTP 404 from provider; check the Base URL path.'
                : '');
        console.warn(`[test:provider] ${input.protocol} ${validated.parsed.hostname} model=${input.model} → ${response.status} in ${latencyMs}ms (${kind})${detail ? ` ${detail}` : ''}`);
        return {
            ok: false,
            kind,
            latencyMs,
            model,
            status: response.status,
            detail,
        };
    }
    catch (err) {
        const latencyMs = Date.now() - start;
        const kind = networkErrorToKind(err);
        const message = err instanceof Error ? err.message : String(err);
        console.warn(`[test:provider] ${input.protocol} ${validated.parsed.hostname} model=${input.model} → ${kind} in ${latencyMs}ms ${redactSecrets(message, [input.apiKey])}`);
        return {
            ok: false,
            kind,
            latencyMs,
            model,
            detail: redactSecrets(message, [input.apiKey]),
        };
    }
    finally {
        clearTimeout(timer);
        input.signal?.removeEventListener('abort', abortFromParent);
    }
}
export function createAgentSink() {
    let buffer = '';
    let stderrTail = '';
    let rawStdoutTail = '';
    let debounceTimer = null;
    let resolveResult;
    let resolveStreamError;
    let settled = false;
    let streamErrorSettled = false;
    const result = new Promise((resolve) => {
        resolveResult = (value) => {
            if (settled)
                return;
            settled = true;
            if (debounceTimer) {
                clearTimeout(debounceTimer);
                debounceTimer = null;
            }
            resolve(value);
        };
    });
    const streamError = new Promise((resolve) => {
        resolveStreamError = (error) => {
            if (streamErrorSettled)
                return;
            streamErrorSettled = true;
            resolve(error);
        };
    });
    const publishStreamError = (error) => {
        resolveStreamError(error);
        resolveResult({ kind: 'streamError', error });
    };
    const scheduleTextResolution = () => {
        if (settled || buffer.trim().length === 0)
            return;
        if (debounceTimer)
            clearTimeout(debounceTimer);
        debounceTimer = setTimeout(() => {
            resolveResult({ kind: 'text', text: buffer });
        }, AGENT_COMPLETION_DEBOUNCE_MS);
        debounceTimer.unref?.();
    };
    const consumeText = (text) => {
        if (typeof text !== 'string' || text.length === 0)
            return;
        buffer += text;
        scheduleTextResolution();
    };
    const appendRawStdout = (chunk) => {
        if (typeof chunk === 'string' && chunk.length > 0) {
            rawStdoutTail = (rawStdoutTail + chunk).slice(-400);
        }
    };
    const send = (event, payload) => {
        const data = (payload ?? {});
        if (event === 'error') {
            const message = typeof data.message === 'string'
                ? data.message
                : typeof data.error?.message === 'string'
                    ? data.error.message
                    : 'agent stream error';
            publishStreamError(new Error(message));
            return;
        }
        if (event === 'agent') {
            const type = data.type;
            if (type === 'error') {
                const message = typeof data.message === 'string' ? data.message : 'agent stream error';
                publishStreamError(new Error(message));
                return;
            }
            const delta = data.delta;
            const text = data.text;
            if (type === 'text_delta' && typeof delta === 'string') {
                consumeText(delta);
            }
            else if (type === 'text' && typeof text === 'string') {
                consumeText(text);
            }
            return;
        }
        if (event === 'stdout') {
            const chunk = data.chunk;
            if (typeof chunk === 'string') {
                appendRawStdout(chunk);
                consumeText(chunk);
            }
            return;
        }
        if (event === 'stderr') {
            const chunk = data.chunk;
            if (typeof chunk === 'string') {
                stderrTail = (stderrTail + chunk).slice(-400);
            }
            return;
        }
        // Ignore 'start', 'status', 'end', 'tool_use', 'thinking', etc. —
        // they don't carry assistant prose.
    };
    return {
        send,
        result,
        streamError,
        getText: () => buffer,
        getStderrTail: () => stderrTail,
        appendRawStdout,
        getRawStdoutTail: () => rawStdoutTail,
        dispose: () => {
            if (debounceTimer) {
                clearTimeout(debounceTimer);
                debounceTimer = null;
            }
        },
    };
}
function attachAgentStreamHandlers(def, child, prompt, cwd, model, send, appendRawStdout) {
    let acpSession = null;
    child.stdout?.setEncoding('utf8');
    child.stderr?.setEncoding('utf8');
    if (def.streamFormat === 'claude-stream-json') {
        const claude = createClaudeStreamHandler((ev) => send('agent', ev));
        child.stdout?.on('data', (chunk) => {
            appendRawStdout?.(chunk);
            claude.feed(chunk);
        });
        child.on('close', () => claude.flush());
    }
    else if (def.streamFormat === 'copilot-stream-json') {
        const copilot = createCopilotStreamHandler((ev) => send('agent', ev));
        child.stdout?.on('data', (chunk) => copilot.feed(chunk));
        child.on('close', () => copilot.flush());
    }
    else if (def.streamFormat === 'pi-rpc') {
        acpSession = attachPiRpcSession({
            child,
            prompt,
            cwd,
            model: model ?? null,
            send,
            imagePaths: [],
        });
    }
    else if (def.streamFormat === 'acp-json-rpc') {
        acpSession = attachAcpSession({
            child,
            prompt,
            cwd,
            model: model ?? null,
            mcpServers: [],
            send,
        });
    }
    else if (def.streamFormat === 'json-event-stream') {
        const handler = createJsonEventStreamHandler(def.eventParser || def.id, (ev) => {
            const data = (ev ?? {});
            if (data.type === 'error') {
                send('error', {
                    message: typeof data.message === 'string'
                        ? data.message
                        : 'agent stream error',
                });
                return;
            }
            send('agent', ev);
        });
        child.stdout?.on('data', (chunk) => handler.feed(chunk));
        child.on('close', () => handler.flush());
    }
    else {
        child.stdout?.on('data', (chunk) => send('stdout', { chunk }));
    }
    child.stderr?.on('data', (chunk) => send('stderr', { chunk }));
    return { child, acpSession };
}
function delay(ms) {
    return new Promise((resolve) => {
        const timer = setTimeout(resolve, ms);
        timer.unref?.();
    });
}
async function testAgentConnectionInternal(input) {
    const start = Date.now();
    const model = typeof input.model === 'string' && input.model.trim()
        ? input.model.trim()
        : 'default';
    const def = getAgentDef(input.agentId);
    if (!def) {
        return {
            ok: false,
            kind: 'agent_not_installed',
            latencyMs: Date.now() - start,
            model,
            agentName: input.agentId,
            detail: `Unknown agent id: ${input.agentId}`,
        };
    }
    const configuredAgentEnv = agentCliEnvForAgent(validateAgentCliEnv(input.agentCliEnv), input.agentId);
    const executableResolution = resolveAgentLaunch(def, configuredAgentEnv);
    const resolvedBin = executableResolution.selectedPath;
    if (!resolvedBin || !executableResolution.launchPath) {
        return {
            ok: false,
            kind: 'agent_not_installed',
            latencyMs: Date.now() - start,
            model,
            agentName: def.name,
        };
    }
    const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-'));
    let child = null;
    let childExit = null;
    let childClosed = false;
    let timer = null;
    let abortHandler = null;
    const sink = createAgentSink();
    const resultFromAgentText = (text) => {
        const latencyMs = Date.now() - start;
        const rawSample = truncateSample(text);
        const sample = redactSecrets(rawSample);
        if (rawSample && isLikelyModelErrorText(rawSample)) {
            const detail = redactSecrets(smokeFailureDetail(rawSample));
            console.warn(`[test:agent] ${def.name} → not_found_model: ${detail}`);
            return {
                ok: false,
                kind: 'not_found_model',
                latencyMs,
                model,
                agentName: def.name,
                detail,
            };
        }
        if (!isSmokeOkReply(text)) {
            console.warn(`[test:agent] ${def.name} → connected_unexpected_sample: ${sample}`);
        }
        console.log(`[test:agent] ${def.name} → ok in ${(latencyMs / 1000).toFixed(1)}s`);
        return {
            ok: true,
            kind: 'success',
            latencyMs,
            model,
            agentName: def.name,
            sample,
        };
    };
    const resultFromStreamError = (error) => {
        const latencyMs = Date.now() - start;
        const detail = redactSecrets(error instanceof Error ? error.message : String(error));
        const auth = classifyAgentAuthFailure(input.agentId, detail);
        if (auth?.status === 'missing') {
            console.warn(`[test:agent] ${def.name} → auth_required: ${detail}`);
            return {
                ok: false,
                kind: 'agent_auth_required',
                latencyMs,
                model,
                agentName: def.name,
                detail: auth.message ?? cursorAuthGuidance(),
            };
        }
        if (detail && isLikelyModelErrorText(detail)) {
            console.warn(`[test:agent] ${def.name} → not_found_model: ${detail}`);
            return {
                ok: false,
                kind: 'not_found_model',
                latencyMs,
                model,
                agentName: def.name,
                detail,
            };
        }
        console.warn(`[test:agent] ${def.name} → stream_error: ${detail}`);
        return {
            ok: false,
            kind: 'agent_spawn_failed',
            latencyMs,
            model,
            agentName: def.name,
            detail,
        };
    };
    const resultFromCancellation = (kind) => {
        const latencyMs = Date.now() - start;
        console.warn(`[test:agent] ${def.name} → ${kind} in ${(latencyMs / 1000).toFixed(1)}s`);
        return {
            ok: false,
            kind: 'timeout',
            latencyMs,
            model,
            agentName: def.name,
        };
    };
    try {
        let args;
        try {
            args = def.buildArgs(SMOKE_PROMPT, [], [], { model: input.model ?? null, reasoning: input.reasoning ?? null }, { cwd: tempDir });
        }
        catch (err) {
            const detail = err instanceof Error ? err.message : String(err);
            return {
                ok: false,
                kind: 'agent_spawn_failed',
                latencyMs: Date.now() - start,
                model,
                agentName: def.name,
                detail: redactSecrets(detail),
            };
        }
        const stdinMode = def.promptViaStdin || def.streamFormat === 'acp-json-rpc' ? 'pipe' : 'ignore';
        const baseEnv = spawnEnvForAgent(input.agentId, {
            ...process.env,
            ...(def.env || {}),
        }, configuredAgentEnv);
        const env = applyAgentLaunchEnv(baseEnv, executableResolution);
        const auth = await probeAgentAuthStatus(input.agentId, executableResolution.launchPath, env);
        if (auth?.status === 'missing') {
            return {
                ok: false,
                kind: 'agent_auth_required',
                latencyMs: Date.now() - start,
                model,
                agentName: def.name,
                detail: auth.message ?? cursorAuthGuidance(),
            };
        }
        const invocation = createCommandInvocation({
            command: executableResolution.launchPath,
            args,
            env,
        });
        child = spawn(invocation.command, invocation.args, {
            env,
            stdio: [stdinMode, 'pipe', 'pipe'],
            cwd: tempDir,
            shell: false,
            windowsVerbatimArguments: invocation.windowsVerbatimArguments,
        });
        childExit = new Promise((resolve) => {
            child.once('error', (err) => {
                childClosed = true;
                resolve({ kind: 'spawnError', error: err });
            });
            child.once('close', (code, signal) => {
                childClosed = true;
                resolve({ kind: 'exit', code, signal });
            });
        });
        const { acpSession } = attachAgentStreamHandlers(def, child, SMOKE_PROMPT, tempDir, input.model, sink.send, sink.appendRawStdout);
        const resultFromChildExit = (winner) => {
            if (winner.kind === 'spawnError') {
                const latencyMs = Date.now() - start;
                const detail = redactSecrets(winner.error.message);
                const guidance = redactSecrets(`${codexExecutableGuidance(input.agentId, executableResolution.configuredOverridePath, executableResolution.pathResolvedPath)}${executableResolution.diagnostic ? ` ${executableResolution.diagnostic}` : ''}`);
                const errnoCode = winner.error.code;
                const isMissing = errnoCode === 'ENOENT';
                console.warn(`[test:agent] ${def.name} → spawn_failed: ${detail}${guidance}`);
                return {
                    ok: false,
                    kind: isMissing ? 'agent_not_installed' : 'agent_spawn_failed',
                    latencyMs,
                    model,
                    agentName: def.name,
                    detail: `${detail}${guidance}`,
                };
            }
            const latencyMs = Date.now() - start;
            const buffered = sink.getText().trim();
            // ACP agents that don't shut down on stdin.end() (e.g. Devin for
            // Terminal) are now SIGTERM'd from attachAcpSession after a clean
            // prompt completion, which sets `winner.signal === 'SIGTERM'`. For
            // that exact forced-shutdown shape we trust the ACP-level success
            // signal so connection tests don't report `agent_spawn_failed`
            // despite a healthy assistant response (see #1265 / #1286).
            //
            // Scope the override narrowly: only `code === null` AND
            // `signal === 'SIGTERM'` AND `acpCleanCompletion` count as a clean
            // forced shutdown. Any other post-response process failure (non-zero
            // exit code, SIGKILL, SIGSEGV, etc.) still falls through to
            // `agent_spawn_failed`, preserving the existing connection-test
            // failure behavior for genuine post-response problems.
            const acpCleanCompletion = typeof acpSession?.completedSuccessfully === 'function' &&
                acpSession.completedSuccessfully();
            const acpForcedShutdown = winner.code === null &&
                winner.signal === 'SIGTERM' &&
                acpCleanCompletion;
            const exitedCleanly = (winner.code === 0 && !winner.signal) || acpForcedShutdown;
            if (buffered) {
                const rawSample = truncateSample(buffered);
                if (rawSample && isLikelyModelErrorText(rawSample)) {
                    return resultFromAgentText(buffered);
                }
                if (exitedCleanly)
                    return resultFromAgentText(buffered);
            }
            const stderrTail = sink.getStderrTail().trim();
            const rawStdoutTail = sink.getRawStdoutTail().trim();
            const acpFatal = Boolean(acpSession?.hasFatalError?.());
            const rawDetail = [
                winner.code != null ? `exit ${winner.code}` : null,
                winner.signal ? `signal ${winner.signal}` : null,
                stderrTail ? `stderr: ${stderrTail.slice(-200)}` : null,
                rawStdoutTail || buffered
                    ? `stdout: ${(rawStdoutTail || buffered).slice(-200)}`
                    : null,
            ]
                .filter(Boolean)
                .join(' · ');
            const auth = classifyAgentAuthFailure(input.agentId, rawDetail);
            if (auth?.status === 'missing') {
                console.warn(`[test:agent] ${def.name} → auth_required: ${redactSecrets(rawDetail)}`);
                return {
                    ok: false,
                    kind: 'agent_auth_required',
                    latencyMs,
                    model,
                    agentName: def.name,
                    detail: auth.message ?? cursorAuthGuidance(),
                };
            }
            const claudeDiagnostic = diagnoseClaudeCliFailure({
                agentId: input.agentId,
                exitCode: winner.code,
                signal: winner.signal,
                stderrTail,
                stdoutTail: rawStdoutTail || buffered,
                env,
            });
            if (claudeDiagnostic) {
                console.warn(`[test:agent] ${def.name} → claude_diagnostic: ${claudeDiagnostic.detail}`);
                return {
                    ok: false,
                    kind: 'agent_spawn_failed',
                    latencyMs,
                    model,
                    agentName: def.name,
                    detail: claudeDiagnostic.detail,
                };
            }
            const detail = redactSecrets(rawDetail);
            const guidance = redactSecrets(`${codexExecutableGuidance(input.agentId, executableResolution.configuredOverridePath, executableResolution.pathResolvedPath)}${executableResolution.diagnostic ? ` ${executableResolution.diagnostic}` : ''}`);
            const label = buffered ? 'exit_failed' : 'no_text';
            console.warn(`[test:agent] ${def.name} → ${label} (${detail || 'no detail'}${guidance})`);
            return {
                ok: false,
                kind: acpFatal || !exitedCleanly ? 'agent_spawn_failed' : 'unknown',
                latencyMs,
                model,
                agentName: def.name,
                detail: `${detail || 'Agent exited without producing assistant text'}${guidance}`,
            };
        };
        if (def.promptViaStdin && child.stdin && def.streamFormat !== 'pi-rpc') {
            child.stdin.on('error', (err) => {
                if (err.code !== 'EPIPE') {
                    sink.send('error', {
                        message: `stdin: ${err.message}`,
                    });
                }
            });
            child.stdin.end(SMOKE_PROMPT, 'utf8');
        }
        const cancellationPromise = new Promise((resolve) => {
            timer = setTimeout(() => resolve({ kind: 'timeout' }), agentTimeoutMs());
            abortHandler = () => resolve({ kind: 'aborted' });
            if (input.signal?.aborted) {
                abortHandler();
            }
            else {
                input.signal?.addEventListener('abort', abortHandler, { once: true });
            }
        });
        const streamError = sink.streamError.then((error) => ({
            kind: 'streamError',
            error,
        }));
        const winner = await Promise.race([
            sink.result,
            childExit,
            cancellationPromise,
        ]);
        if (winner.kind === 'text') {
            const completion = await Promise.race([
                streamError,
                childExit,
                cancellationPromise,
            ]);
            if (completion.kind === 'streamError') {
                return resultFromStreamError(completion.error);
            }
            if (completion.kind === 'timeout' || completion.kind === 'aborted') {
                return resultFromCancellation(completion.kind);
            }
            return resultFromChildExit(completion);
        }
        if (winner.kind === 'streamError') {
            return resultFromStreamError(winner.error);
        }
        if (winner.kind === 'timeout' || winner.kind === 'aborted') {
            return resultFromCancellation(winner.kind);
        }
        return resultFromChildExit(winner);
    }
    catch (err) {
        const detail = err instanceof Error ? err.message : String(err);
        return {
            ok: false,
            kind: 'agent_spawn_failed',
            latencyMs: Date.now() - start,
            model,
            agentName: def.name,
            detail: redactSecrets(detail),
        };
    }
    finally {
        if (timer)
            clearTimeout(timer);
        if (abortHandler) {
            input.signal?.removeEventListener('abort', abortHandler);
        }
        sink.dispose();
        if (child && !childClosed) {
            try {
                child.kill('SIGTERM');
            }
            catch {
                // Already gone — nothing to do.
            }
            const closedAfterTerm = childExit
                ? await Promise.race([
                    childExit.then(() => true),
                    delay(AGENT_KILL_GRACE_MS).then(() => false),
                ])
                : false;
            if (!closedAfterTerm && !childClosed) {
                try {
                    child.kill('SIGKILL');
                }
                catch {
                    // Already gone — nothing to do.
                }
                if (childExit) {
                    await Promise.race([
                        childExit.catch(() => null),
                        delay(AGENT_KILL_GRACE_MS),
                    ]);
                }
            }
        }
        await fsp
            .rm(tempDir, { recursive: true, force: true })
            .catch(() => {
            // Best-effort cleanup; the OS reaps /tmp eventually.
        });
    }
}
export async function testAgentConnection(input) {
    const primaryResult = await testAgentConnectionInternal(input);
    const validatedPrefs = validateAgentCliEnv(input.agentCliEnv);
    const configuredCodexBin = validatedPrefs?.codex?.CODEX_BIN?.trim() || '';
    const configuredAgentEnv = agentCliEnvForAgent(validatedPrefs, input.agentId);
    const def = getAgentDef(input.agentId);
    const executableResolution = def
        ? resolveAgentLaunch(def, configuredAgentEnv)
        : {
            configuredOverridePath: null,
            pathResolvedPath: null,
            selectedPath: null,
            launchPath: null,
            launchKind: 'selected',
            childPathPrepend: [],
            diagnostic: null,
        };
    if (input.agentId === 'codex' &&
        primaryResult.ok &&
        configuredCodexBin) {
        if (executableResolution.configuredOverridePath) {
            return {
                ...primaryResult,
                configuredExecutablePath: executableResolution.configuredOverridePath,
                usedExecutablePath: executableResolution.launchPath ?? executableResolution.configuredOverridePath,
                usedExecutableSource: 'configured',
                ...(executableResolution.pathResolvedPath
                    ? { detectedExecutablePath: executableResolution.pathResolvedPath }
                    : {}),
                detail: redactSecrets(codexConfiguredPathSuccessDetail(executableResolution.configuredOverridePath)),
            };
        }
        if (executableResolution.pathResolvedPath) {
            return {
                ...primaryResult,
                configuredExecutablePath: configuredCodexBin,
                detectedExecutablePath: executableResolution.pathResolvedPath,
                usedExecutablePath: executableResolution.launchPath ?? executableResolution.pathResolvedPath,
                usedExecutableSource: 'fallback_invalid',
                detail: redactSecrets(codexInvalidConfiguredPathFallbackDetail(configuredCodexBin, executableResolution.pathResolvedPath)),
            };
        }
    }
    if (input.agentId !== 'codex' ||
        primaryResult.ok ||
        !new Set(['agent_spawn_failed', 'agent_not_installed', 'unknown']).has(primaryResult.kind) ||
        !executableResolution.configuredOverridePath ||
        !executableResolution.pathResolvedPath ||
        executableResolution.configuredOverridePath === executableResolution.pathResolvedPath) {
        return primaryResult;
    }
    const fallbackResult = await testAgentConnectionInternal({
        ...input,
        agentCliEnv: stripCodexBinOverride(validatedPrefs),
    });
    if (!fallbackResult.ok) {
        return primaryResult;
    }
    return {
        ...fallbackResult,
        configuredExecutablePath: executableResolution.configuredOverridePath,
        detectedExecutablePath: executableResolution.pathResolvedPath,
        usedExecutablePath: executableResolution.launchPath ?? executableResolution.pathResolvedPath,
        usedExecutableSource: 'fallback_failed',
        detail: redactSecrets(codexExecutableFallbackSuccessDetail(executableResolution.configuredOverridePath, executableResolution.pathResolvedPath)),
    };
}
//# sourceMappingURL=connectionTest.js.map