fix: use tail -1 for readLastJsonlEntry, add real-data integration test

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 <noreply@anthropic.com>
This commit is contained in:
Prateek 2026-02-18 03:42:47 +05:30
parent eb2fae28dd
commit c8a5028606
2 changed files with 137 additions and 55 deletions

View File

@ -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<string | null> {
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);

View File

@ -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)", () => {