/** * Integration tests for the Claude Code agent plugin. * * Two test suites: * * 1. "path encoding & JSONL reading" — validates that toClaudeProjectPath() * resolves to real ~/.claude/projects/ directories and readLastJsonlEntry() * can parse real session files. Only requires Claude to have been run at * least once on this machine (no API key, no tmux needed). * * 2. "agent-claude-code (integration)" — full lifecycle test spawning a real * Claude process. Requires claude binary, tmux, and ANTHROPIC_API_KEY. * * Skipped automatically when prerequisites are missing. */ import { execFile } from "node:child_process"; import { mkdtemp, readdir, realpath, rm } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { readLastJsonlEntry, type ActivityDetection, type AgentSessionInfo, } from "@composio/ao-core"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import claudeCodePlugin, { toClaudeProjectPath } from "@composio/ao-plugin-agent-claude-code"; import { isTmuxAvailable, killSessionsByPrefix, createSession, killSession, } from "./helpers/tmux.js"; import { pollUntilEqual, sleep } from "./helpers/polling.js"; import { makeTmuxHandle, makeSession } from "./helpers/session-factory.js"; const execFileAsync = promisify(execFile); // --------------------------------------------------------------------------- // Prerequisites // --------------------------------------------------------------------------- const SESSION_PREFIX = "ao-inttest-claude-"; async function findClaudeBinary(): Promise { for (const bin of ["claude"]) { try { await execFileAsync("which", [bin], { timeout: 5_000 }); return bin; } catch { // not found } } return null; } const tmuxOk = await isTmuxAvailable(); const claudeBin = await findClaudeBinary(); const hasApiKey = Boolean(process.env.ANTHROPIC_API_KEY); const canRun = tmuxOk && claudeBin !== null && hasApiKey; // --------------------------------------------------------------------------- // Path encoding & JSONL reading — real ~/.claude/projects/ validation // --------------------------------------------------------------------------- /** * Find a workspace path on this machine that has a matching Claude project * directory with at least one JSONL session file. Returns null if Claude * has never been used (test will skip). */ async function findRealClaudeProject(): Promise<{ workspacePath: string; projectDir: string; jsonlFile: string; } | null> { const claudeProjectsDir = join(homedir(), ".claude", "projects"); let dirs: string[]; try { dirs = await readdir(claudeProjectsDir); } catch { return null; } for (const dir of dirs) { // Reverse the encoding: leading dash → leading slash, internal dashes → slashes // This is a heuristic — we just need one workspace that exists on disk const projectDir = join(claudeProjectsDir, dir); let files: string[]; try { files = await readdir(projectDir); } catch { continue; } const jsonlFiles = files.filter((f) => f.endsWith(".jsonl") && !f.startsWith("agent-")); if (jsonlFiles.length === 0) continue; // Try to reconstruct the workspace path from the encoded dir name // Encoded format: /Users/dev/project → -Users-dev-project // We can't perfectly reverse this (dashes are ambiguous), but we can // verify the forward direction: encode known paths and check if they match const candidatePath = "/" + dir.slice(1).replace(/-/g, "/"); const reEncoded = toClaudeProjectPath(candidatePath); if (reEncoded === dir) { return { workspacePath: candidatePath, projectDir, jsonlFile: join(projectDir, jsonlFiles[0]), }; } } return null; } const realProject = await findRealClaudeProject(); describe.skipIf(!realProject)("path encoding & JSONL reading (real Claude data)", () => { it("toClaudeProjectPath resolves to a real ~/.claude/projects/ directory", () => { const encoded = toClaudeProjectPath(realProject!.workspacePath); const expectedDir = join(homedir(), ".claude", "projects", encoded); expect(expectedDir).toBe(realProject!.projectDir); }); it("readLastJsonlEntry parses a real session JSONL file", async () => { const entry = await readLastJsonlEntry(realProject!.jsonlFile); expect(entry).not.toBeNull(); expect(entry!.modifiedAt).toBeInstanceOf(Date); // lastType should be a known Claude message type or null (not undefined) expect(entry!.lastType === null || typeof entry!.lastType === "string").toBe(true); }); it("readLastJsonlEntry returns a recognized message type", async () => { const entry = await readLastJsonlEntry(realProject!.jsonlFile); if (entry?.lastType) { const knownTypes = [ "user", "assistant", "system", "last-prompt", "tool_use", "progress", "permission_request", "error", "summary", "result", "file-history-snapshot", "queue-operation", "pr-link", ]; expect(knownTypes).toContain(entry.lastType); } }); it("getActivityState returns a valid state for a real workspace path", async () => { const agent = claudeCodePlugin.create(); // Mock isProcessRunning to return false — we're not testing process detection, // we're testing path resolution and JSONL parsing vi.spyOn(agent, "isProcessRunning").mockResolvedValue(false); const handle = makeTmuxHandle("fake-session"); const session = makeSession("real-path-test", handle, realProject!.workspacePath); const state = await agent.getActivityState(session); // Process is "not running" so should get "exited" — but the important thing // is it didn't return null (which would mean the path didn't resolve) expect(state).not.toBeNull(); expect(state?.state ?? state).toBe("exited"); }); }); // --------------------------------------------------------------------------- // Full lifecycle test (requires claude binary + API key + tmux) // --------------------------------------------------------------------------- describe.skipIf(!canRun)("agent-claude-code (integration)", () => { const agent = claudeCodePlugin.create(); const sessionName = `${SESSION_PREFIX}${Date.now()}`; let tmpDir: string; // Observations captured while the agent is alive let aliveRunning = false; let aliveActivityState: ActivityDetection | null | undefined; let aliveSessionInfo: AgentSessionInfo | null = null; // Observations captured after the agent exits let exitedRunning: boolean; let exitedActivityState: ActivityDetection | null; let exitedSessionInfo: AgentSessionInfo | null; beforeAll(async () => { await killSessionsByPrefix(SESSION_PREFIX); // Create temp workspace — resolve symlinks (macOS /tmp → /private/tmp) const raw = await mkdtemp(join(tmpdir(), "ao-inttest-claude-")); tmpDir = await realpath(raw); // Spawn Claude with a task that generates observable activity (file creation) const cmd = `CLAUDECODE= ${claudeBin} -p 'Create a file called test.txt with the content "integration test"'`; await createSession(sessionName, cmd, tmpDir); const handle = makeTmuxHandle(sessionName); const session = makeSession("inttest-claude", handle, tmpDir); // Poll until we capture "alive" observations // Claude needs time to start, create JSONL, and begin processing const deadline = Date.now() + 30_000; while (Date.now() < deadline) { const running = await agent.isProcessRunning(handle); if (running) { aliveRunning = true; try { const activityState = await agent.getActivityState(session); if (activityState?.state !== "exited") { aliveActivityState = activityState; // Also capture session info while alive aliveSessionInfo = await agent.getSessionInfo(session); break; } } catch { // JSONL might not exist yet, keep polling } } await sleep(1_000); } // Wait for agent to exit (simple task should complete within 90s) exitedRunning = await pollUntilEqual(() => agent.isProcessRunning(handle), false, { timeoutMs: 90_000, intervalMs: 2_000, }); // Capture post-exit observations exitedActivityState = await agent.getActivityState(session); exitedSessionInfo = await agent.getSessionInfo(session); }, 150_000); afterAll(async () => { await killSession(sessionName); if (tmpDir) { await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); } }, 30_000); it("isProcessRunning → true while agent is alive", () => { expect(aliveRunning).toBe(true); }); it("getActivityState → returns valid non-exited state while agent is alive", () => { expect(aliveActivityState).toBeDefined(); expect(aliveActivityState?.state).not.toBe("exited"); // May be null (no JSONL yet) or a concrete state expect([null, "active", "ready", "idle", "waiting_input", "blocked"]).toContain( aliveActivityState?.state ?? null, ); }); it("getSessionInfo → returns session data while agent is alive (or null if path mismatch)", () => { // The JSONL path depends on Claude's internal encoding of workspacePath. // If the temp dir resolves differently (symlinks, etc.), may return null. // Both outcomes are acceptable — the key is it doesn't throw. if (aliveSessionInfo !== null) { expect(aliveSessionInfo).toHaveProperty("summary"); expect(aliveSessionInfo).toHaveProperty("agentSessionId"); expect(typeof aliveSessionInfo.agentSessionId).toBe("string"); } }); it("isProcessRunning → false after agent exits", () => { expect(exitedRunning).toBe(false); }); it("getActivityState → returns exited after agent process terminates", () => { expect(exitedActivityState?.state).toBe("exited"); }); it("getSessionInfo → returns session data after agent exits (or null if path mismatch)", () => { // JSONL should still be readable after exit, but path encoding may cause null. // Both outcomes are acceptable — the key is it doesn't throw. if (exitedSessionInfo !== null) { expect(exitedSessionInfo).toHaveProperty("summary"); expect(exitedSessionInfo).toHaveProperty("agentSessionId"); expect(typeof exitedSessionInfo.agentSessionId).toBe("string"); } }); });