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
48 changes: 27 additions & 21 deletions apps/desktop/src/main/packagedRuntimeSmoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,37 @@ const execFileAsync = promisify(execFile);
const PTY_PROBE_TIMEOUT_MS = 4_000;
const CLAUDE_PROBE_TIMEOUT_MS = 20_000;

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

const AUTH_FAILURE_PATTERNS = [
"not authenticated",
"not logged in",
"authentication required",
"authentication error",
"authentication_error",
"login required",
"sign in",
"claude auth login",
"/login",
"authentication_failed",
"invalid authentication credentials",
"invalid api key",
"api error: 401",
"status code: 401",
"status 401",
];

function isClaudeAuthFailureMessage(input: unknown): boolean {
const text = input instanceof Error ? input.message : String(input ?? "");
const lower = text.toLowerCase();
return (
lower.includes("not authenticated")
|| lower.includes("not logged in")
|| lower.includes("authentication required")
|| lower.includes("authentication error")
|| lower.includes("authentication_error")
|| lower.includes("login required")
|| lower.includes("sign in")
|| lower.includes("claude auth login")
|| lower.includes("/login")
|| lower.includes("authentication_failed")
|| lower.includes("invalid authentication credentials")
|| lower.includes("invalid api key")
|| lower.includes("api error: 401")
|| lower.includes("status code: 401")
|| lower.includes("status 401")
);
return AUTH_FAILURE_PATTERNS.some((pattern) => lower.includes(pattern));
}

async function probePty(): Promise<{ ok: true; output: string }> {
const pty = await import("node-pty");
return await new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
let output = "";
const term = pty.spawn("/bin/sh", ["-lc", 'printf "ADE_PTY_OK\\n"'], {
name: "xterm-256color",
Expand Down Expand Up @@ -124,12 +130,12 @@ async function probeClaudeStartup(
if (isClaudeAuthFailureMessage(error)) {
return {
state: "auth-failed",
message: error instanceof Error ? error.message : String(error),
message: errorMessage(error),
};
}
return {
state: "runtime-failed",
message: error instanceof Error ? error.message : String(error),
message: errorMessage(error),
};
} finally {
clearTimeout(timeout);
Expand Down Expand Up @@ -193,6 +199,6 @@ async function main(): Promise<void> {
}

void main().catch((error) => {
process.stderr.write(error instanceof Error ? error.stack ?? error.message : String(error));
process.stderr.write(error instanceof Error ? (error.stack ?? error.message) : String(error));
process.exit(1);
});
65 changes: 61 additions & 4 deletions apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,18 @@ const mockState = vi.hoisted(() => ({
env: { ADE_PROJECT_ROOT: "/tmp/project" },
},
})),
resolveAdeMcpServerLaunch: vi.fn(() => ({
resolveDesktopAdeMcpLaunch: vi.fn(() => ({
mode: "headless_source",
command: "node",
cmdArgs: ["probe.js"],
env: { ADE_PROJECT_ROOT: "/tmp/project" },
entryPath: "probe.js",
runtimeRoot: "/tmp/runtime",
socketPath: "/tmp/project/.ade/mcp.sock",
packaged: false,
resourcesPath: null,
})),
resolveRepoRuntimeRoot: vi.fn(() => "/tmp/runtime"),
}));

vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
Expand All @@ -39,8 +46,9 @@ vi.mock("./providerResolver", () => ({
normalizeCliMcpServers: mockState.normalizeCliMcpServers,
}));

vi.mock("../orchestrator/unifiedOrchestratorAdapter", () => ({
resolveAdeMcpServerLaunch: mockState.resolveAdeMcpServerLaunch,
vi.mock("../runtime/adeMcpLaunch", () => ({
resolveDesktopAdeMcpLaunch: mockState.resolveDesktopAdeMcpLaunch,
resolveRepoRuntimeRoot: mockState.resolveRepoRuntimeRoot,
}));

let probeClaudeRuntimeHealth: typeof import("./claudeRuntimeProbe").probeClaudeRuntimeHealth;
Expand Down Expand Up @@ -68,7 +76,8 @@ beforeEach(async () => {
mockState.reportProviderRuntimeFailure.mockReset();
mockState.resolveClaudeCodeExecutable.mockClear();
mockState.normalizeCliMcpServers.mockClear();
mockState.resolveAdeMcpServerLaunch.mockClear();
mockState.resolveDesktopAdeMcpLaunch.mockClear();
mockState.resolveRepoRuntimeRoot.mockClear();
const mod = await import("./claudeRuntimeProbe");
probeClaudeRuntimeHealth = mod.probeClaudeRuntimeHealth;
resetClaudeRuntimeProbeCache = mod.resetClaudeRuntimeProbeCache;
Expand Down Expand Up @@ -153,4 +162,52 @@ describe("claudeRuntimeProbe", () => {
expect(mockState.reportProviderRuntimeAuthFailure).toHaveBeenCalledTimes(1);
expect(mockState.reportProviderRuntimeFailure).not.toHaveBeenCalled();
});

it("calls resolveDesktopAdeMcpLaunch with defaultRole external and projectRoot", async () => {
const query = makeStream([
{
type: "result",
subtype: "success",
duration_ms: 50,
duration_api_ms: 50,
is_error: false,
num_turns: 1,
result: "ok",
session_id: "session-ok",
total_cost_usd: 0.001,
usage: {
input_tokens: 10,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
output_tokens: 5,
server_tool_use: { web_search_requests: 0 },
service_tier: "standard",
},
},
]);
mockState.query.mockReturnValue(query.stream);

await probeClaudeRuntimeHealth({ projectRoot: "/my/custom/project", force: true });

expect(mockState.resolveDesktopAdeMcpLaunch).toHaveBeenCalledWith(
expect.objectContaining({
projectRoot: "/my/custom/project",
workspaceRoot: "/my/custom/project",
defaultRole: "external",
}),
);
expect(mockState.resolveRepoRuntimeRoot).toHaveBeenCalled();
expect(mockState.reportProviderRuntimeReady).toHaveBeenCalledTimes(1);
});

it("reports runtime-failed when the probe stream throws an error", async () => {
mockState.query.mockImplementation(() => {
throw new Error("spawn ENOENT");
});

await probeClaudeRuntimeHealth({ projectRoot: "/tmp/project", force: true });

expect(mockState.reportProviderRuntimeFailure).toHaveBeenCalledTimes(1);
expect(mockState.reportProviderRuntimeAuthFailure).not.toHaveBeenCalled();
});
});
44 changes: 16 additions & 28 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1083,8 +1083,7 @@ function isLightweightSession(session: Pick<AgentChatSession, "sessionProfile">)

let _mcpRuntimeRootCache: string | null = null;
function resolveMcpRuntimeRoot(): string {
if (_mcpRuntimeRootCache !== null) return _mcpRuntimeRootCache;
_mcpRuntimeRootCache = resolveUnifiedRuntimeRoot();
_mcpRuntimeRootCache ??= resolveUnifiedRuntimeRoot();
return _mcpRuntimeRootCache;
}

Expand Down Expand Up @@ -1342,22 +1341,25 @@ export function createAgentChatService(args: {
ownerId?: string | null;
computerUsePolicy?: ComputerUsePolicy | null;
}) => {
const launch = resolveAdeMcpServerLaunch({
const { mode, command, entryPath, runtimeRoot, socketPath, packaged, resourcesPath } = resolveAdeMcpServerLaunch({
workspaceRoot: projectRoot,
runtimeRoot: resolveMcpRuntimeRoot(),
defaultRole: args.defaultRole,
ownerId: args.ownerId ?? undefined,
computerUsePolicy: normalizeComputerUsePolicy(args.computerUsePolicy, createDefaultComputerUsePolicy()),
});
return {
mode: launch.mode,
command: launch.command,
entryPath: launch.entryPath,
runtimeRoot: launch.runtimeRoot,
socketPath: launch.socketPath,
packaged: launch.packaged,
resourcesPath: launch.resourcesPath,
};
return { mode, command, entryPath, runtimeRoot, socketPath, packaged, resourcesPath };
};

/** Best-effort diagnostic: resolve the MCP launch config for a session, returning undefined on failure. */
const tryDiagnosticMcpLaunch = (managed: ManagedChatSession): ReturnType<typeof summarizeAdeMcpLaunch> | undefined => {
try {
return summarizeAdeMcpLaunch({
defaultRole: managed.session.identityKey === "cto" ? "cto" : "agent",
ownerId: resolveWorkerIdentityAgentId(managed.session.identityKey),
computerUsePolicy: managed.session.computerUse,
});
} catch { return undefined; }
};

const readTranscriptConversationEntries = (managed: ManagedChatSession): string[] => {
Expand Down Expand Up @@ -4692,14 +4694,7 @@ export function createAgentChatService(args: {
};

const startCodexRuntime = async (managed: ManagedChatSession): Promise<CodexRuntime> => {
let adeMcpLaunch: ReturnType<typeof summarizeAdeMcpLaunch> | undefined;
try {
adeMcpLaunch = summarizeAdeMcpLaunch({
defaultRole: managed.session.identityKey === "cto" ? "cto" : "agent",
ownerId: resolveWorkerIdentityAgentId(managed.session.identityKey),
computerUsePolicy: managed.session.computerUse,
});
} catch { /* best-effort diagnostic — must not block Codex startup */ }
const adeMcpLaunch = tryDiagnosticMcpLaunch(managed);

logger.info("agent_chat.codex_runtime_start", {
sessionId: managed.session.id,
Expand Down Expand Up @@ -5191,17 +5186,10 @@ export function createAgentChatService(args: {
);
}
let diagClaudePath: string | undefined;
let diagMcpLaunch: ReturnType<typeof summarizeAdeMcpLaunch> | undefined;
try {
diagClaudePath = runtime.v2Session ? undefined : buildClaudeV2SessionOpts(managed, runtime).pathToClaudeCodeExecutable;
} catch { /* best-effort diagnostic */ }
try {
diagMcpLaunch = summarizeAdeMcpLaunch({
defaultRole: managed.session.identityKey === "cto" ? "cto" : "agent",
ownerId: resolveWorkerIdentityAgentId(managed.session.identityKey),
computerUsePolicy: managed.session.computerUse,
});
} catch { /* best-effort diagnostic */ }
const diagMcpLaunch = tryDiagnosticMcpLaunch(managed);
logger.warn("agent_chat.claude_v2_prewarm_failed", {
sessionId: managed.session.id,
error: error instanceof Error ? error.message : String(error),
Expand Down
Loading