From c8a50286068c103e732d124c07d39e45d408ebd1 Mon Sep 17 00:00:00 2001 From: Prateek Date: Wed, 18 Feb 2026 03:42:47 +0530 Subject: [PATCH] fix: use tail -1 for readLastJsonlEntry, add real-data integration test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace over-engineered pure Node.js backward-reading implementation with simple `tail -1` via execFile. The codebase already shells out to tmux, git, and ps everywhere — tail is no different. Add integration test that validates toClaudeProjectPath() and readLastJsonlEntry() against real ~/.claude/projects/ data on disk. No API key needed — just requires Claude to have been run once. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/utils.ts | 56 ++------ .../src/agent-claude-code.integration.test.ts | 136 ++++++++++++++++-- 2 files changed, 137 insertions(+), 55 deletions(-) diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index d387d6559..253cde379 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -2,7 +2,11 @@ * Shared utility functions for agent-orchestrator plugins. */ -import { open, stat } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { stat } from "node:fs/promises"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); /** * POSIX-safe shell escaping: wraps value in single quotes, @@ -32,51 +36,9 @@ export function validateUrl(url: string, label: string): void { } } -/** - * Read the last line from a file by reading backwards from the end. - * Pure Node.js — no external binaries. Handles any file size. - */ -async function readLastLine(filePath: string): Promise { - const CHUNK = 4096; - const fh = await open(filePath, "r"); - try { - const { size } = await fh.stat(); - if (size === 0) return null; - - // Read backwards in chunks, accumulating bytes until we find a newline - const buf = Buffer.alloc(Math.min(CHUNK, size)); - let tail = ""; - let pos = size; - - while (pos > 0) { - const readSize = Math.min(CHUNK, pos); - pos -= readSize; - await fh.read(buf, 0, readSize, pos); - tail = buf.toString("utf-8", 0, readSize) + tail; - - // Find the last non-empty line - const lines = tail.split("\n"); - // Walk from end to find a non-empty line - for (let i = lines.length - 1; i >= 0; i--) { - const line = lines[i].trim(); - if (line) { - // If i > 0, we have a complete line (there's a newline before it) - // If i === 0 and pos === 0, we've read the whole file — line is complete - // If i === 0 and pos > 0, the line may be truncated — keep reading - if (i > 0 || pos === 0) return line; - } - } - } - - return tail.trim() || null; - } finally { - await fh.close(); - } -} - /** * Read the last entry from a JSONL file. - * Reads backwards from end of file — pure Node.js, no external binaries. + * Uses `tail -1` to efficiently read the last line, then JSON.parse in Node. * * @param filePath - Path to the JSONL file * @returns Object containing the last entry's type and file mtime, or null if empty/invalid @@ -85,8 +47,12 @@ export async function readLastJsonlEntry( filePath: string, ): Promise<{ lastType: string | null; modifiedAt: Date } | null> { try { - const [line, fileStat] = await Promise.all([readLastLine(filePath), stat(filePath)]); + const [{ stdout }, fileStat] = await Promise.all([ + execFileAsync("tail", ["-1", filePath], { timeout: 5_000 }), + stat(filePath), + ]); + const line = stdout.trim(); if (!line) return null; const parsed: unknown = JSON.parse(line); diff --git a/packages/integration-tests/src/agent-claude-code.integration.test.ts b/packages/integration-tests/src/agent-claude-code.integration.test.ts index d00def771..3ebecb339 100644 --- a/packages/integration-tests/src/agent-claude-code.integration.test.ts +++ b/packages/integration-tests/src/agent-claude-code.integration.test.ts @@ -1,22 +1,27 @@ /** * Integration tests for the Claude Code agent plugin. * - * Requires: - * - `claude` binary on PATH - * - tmux installed and running - * - ANTHROPIC_API_KEY set (Claude will make a real API call) + * 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, realpath, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +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 type { ActivityState, AgentSessionInfo } from "@composio/ao-core"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import claudeCodePlugin from "@composio/ao-plugin-agent-claude-code"; +import { readLastJsonlEntry, type ActivityState, 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, @@ -52,7 +57,118 @@ const hasApiKey = Boolean(process.env.ANTHROPIC_API_KEY); const canRun = tmuxOk && claudeBin !== null && hasApiKey; // --------------------------------------------------------------------------- -// Tests +// 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", + "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).toBe("exited"); + }); +}); + +// --------------------------------------------------------------------------- +// Full lifecycle test (requires claude binary + API key + tmux) // --------------------------------------------------------------------------- describe.skipIf(!canRun)("agent-claude-code (integration)", () => {