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:
parent
eb2fae28dd
commit
c8a5028606
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue