fix(ci): resolve lint, typecheck, and test failures

Lint:
- Remove unused parseJsonlFile function (superseded by parseJsonlFileTail)
- Remove dead lastLogModified stat() call in getSessionInfo (field was
  removed from AgentSessionInfo but the filesystem read was left behind)

Typecheck + Tests (ActivityDetection):
- session-manager.test.ts: update mocks to return { state: "active" } /
  { state: "idle" } instead of bare strings — getActivityState() returns
  ActivityDetection | null, not ActivityState | null
- integration tests (aider, claude-code, codex, opencode): update imports
  from ActivityState → ActivityDetection, variable types, comparisons
  (activityState?.state !== "exited"), and assertions (?.state).toBe()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Prateek 2026-02-20 03:50:05 +05:30
parent 55a63401db
commit 758e6da63d
6 changed files with 52 additions and 44 deletions

View File

@ -55,7 +55,7 @@ beforeEach(() => {
getLaunchCommand: vi.fn().mockReturnValue("mock-agent --start"),
getEnvironment: vi.fn().mockReturnValue({ AGENT_VAR: "1" }),
detectActivity: vi.fn().mockReturnValue("active"),
getActivityState: vi.fn().mockResolvedValue("active"),
getActivityState: vi.fn().mockResolvedValue({ state: "active" }),
isProcessRunning: vi.fn().mockResolvedValue(true),
getSessionInfo: vi.fn().mockResolvedValue(null),
};
@ -421,7 +421,7 @@ describe("list", () => {
it("detects activity using agent-native mechanism", async () => {
const agentWithState: Agent = {
...mockAgent,
getActivityState: vi.fn().mockResolvedValue("active"),
getActivityState: vi.fn().mockResolvedValue({ state: "active" }),
};
const registryWithState: PluginRegistry = {
...mockRegistry,
@ -535,7 +535,7 @@ describe("get", () => {
it("detects activity using agent-native mechanism", async () => {
const agentWithState: Agent = {
...mockAgent,
getActivityState: vi.fn().mockResolvedValue("idle"),
getActivityState: vi.fn().mockResolvedValue({ state: "idle" }),
};
const registryWithState: PluginRegistry = {
...mockRegistry,

View File

@ -14,7 +14,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import type { ActivityState, AgentSessionInfo } from "@composio/ao-core";
import type { ActivityDetection, AgentSessionInfo } from "@composio/ao-core";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import aiderPlugin from "@composio/ao-plugin-agent-aider";
import {
@ -93,11 +93,11 @@ describe.skipIf(!canRun)("agent-aider (integration)", () => {
// Observations captured while the agent is alive
let aliveRunning = false;
let aliveActivityState: ActivityState | null | undefined;
let aliveActivityState: ActivityDetection | null | undefined;
// Observations captured after the agent exits
let exitedRunning: boolean;
let exitedActivityState: ActivityState | null;
let exitedActivityState: ActivityDetection | null;
let sessionInfo: AgentSessionInfo | null;
beforeAll(async () => {
@ -119,7 +119,7 @@ describe.skipIf(!canRun)("agent-aider (integration)", () => {
if (running) {
aliveRunning = true;
const activityState = await agent.getActivityState(session);
if (activityState !== "exited") {
if (activityState?.state !== "exited") {
aliveActivityState = activityState;
break;
}
@ -152,9 +152,9 @@ describe.skipIf(!canRun)("agent-aider (integration)", () => {
// Aider checks git commits and chat history mtime for activity detection.
// May return null if no chat history exists yet.
if (aliveActivityState !== undefined) {
expect(aliveActivityState).not.toBe("exited");
expect(aliveActivityState?.state).not.toBe("exited");
expect([null, "active", "ready", "idle", "waiting_input", "blocked"]).toContain(
aliveActivityState,
aliveActivityState?.state ?? null,
);
}
});
@ -164,7 +164,7 @@ describe.skipIf(!canRun)("agent-aider (integration)", () => {
});
it("getActivityState → returns exited after agent process terminates", () => {
expect(exitedActivityState).toBe("exited");
expect(exitedActivityState?.state).toBe("exited");
});
it("getSessionInfo → null (not implemented for aider)", () => {

View File

@ -19,7 +19,7 @@ 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 ActivityState, type AgentSessionInfo } from "@composio/ao-core";
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 {
@ -178,12 +178,12 @@ describe.skipIf(!canRun)("agent-claude-code (integration)", () => {
// Observations captured while the agent is alive
let aliveRunning = false;
let aliveActivityState: ActivityState | null | undefined;
let aliveActivityState: ActivityDetection | null | undefined;
let aliveSessionInfo: AgentSessionInfo | null = null;
// Observations captured after the agent exits
let exitedRunning: boolean;
let exitedActivityState: ActivityState | null;
let exitedActivityState: ActivityDetection | null;
let exitedSessionInfo: AgentSessionInfo | null;
beforeAll(async () => {
@ -209,7 +209,7 @@ describe.skipIf(!canRun)("agent-claude-code (integration)", () => {
aliveRunning = true;
try {
const activityState = await agent.getActivityState(session);
if (activityState !== "exited") {
if (activityState?.state !== "exited") {
aliveActivityState = activityState;
// Also capture session info while alive
aliveSessionInfo = await agent.getSessionInfo(session);
@ -246,10 +246,10 @@ describe.skipIf(!canRun)("agent-claude-code (integration)", () => {
it("getActivityState → returns valid non-exited state while agent is alive", () => {
expect(aliveActivityState).toBeDefined();
expect(aliveActivityState).not.toBe("exited");
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,
aliveActivityState?.state ?? null,
);
});
@ -269,7 +269,7 @@ describe.skipIf(!canRun)("agent-claude-code (integration)", () => {
});
it("getActivityState → returns exited after agent process terminates", () => {
expect(exitedActivityState).toBe("exited");
expect(exitedActivityState?.state).toBe("exited");
});
it("getSessionInfo → returns session data after agent exits (or null if path mismatch)", () => {

View File

@ -14,7 +14,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import type { ActivityState, AgentSessionInfo } from "@composio/ao-core";
import type { ActivityDetection, AgentSessionInfo } from "@composio/ao-core";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import codexPlugin from "@composio/ao-plugin-agent-codex";
import {
@ -62,11 +62,11 @@ describe.skipIf(!canRun)("agent-codex (integration)", () => {
// Observations captured while the agent is alive
let aliveRunning = false;
let aliveActivityState: ActivityState | null | undefined;
let aliveActivityState: ActivityDetection | null | undefined;
// Observations captured after the agent exits
let exitedRunning: boolean;
let exitedActivityState: ActivityState | null;
let exitedActivityState: ActivityDetection | null;
let sessionInfo: AgentSessionInfo | null;
beforeAll(async () => {
@ -86,7 +86,7 @@ describe.skipIf(!canRun)("agent-codex (integration)", () => {
if (running) {
aliveRunning = true;
const activityState = await agent.getActivityState(session);
if (activityState !== "exited") {
if (activityState?.state !== "exited") {
aliveActivityState = activityState;
break;
}
@ -128,7 +128,7 @@ describe.skipIf(!canRun)("agent-codex (integration)", () => {
});
it("getActivityState → returns exited after agent process terminates", () => {
expect(exitedActivityState).toBe("exited");
expect(exitedActivityState?.state).toBe("exited");
});
it("getSessionInfo → null (not implemented for codex)", () => {

View File

@ -14,7 +14,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import type { ActivityState, AgentSessionInfo } from "@composio/ao-core";
import type { ActivityDetection, AgentSessionInfo } from "@composio/ao-core";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import opencodePlugin from "@composio/ao-plugin-agent-opencode";
import {
@ -86,11 +86,11 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => {
// Observations captured while the agent is alive
let aliveRunning = false;
let aliveActivityState: ActivityState | null | undefined;
let aliveActivityState: ActivityDetection | null | undefined;
// Observations captured after the agent exits
let exitedRunning: boolean;
let exitedActivityState: ActivityState | null;
let exitedActivityState: ActivityDetection | null;
let sessionInfo: AgentSessionInfo | null;
beforeAll(async () => {
@ -110,7 +110,7 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => {
if (running) {
aliveRunning = true;
const activityState = await agent.getActivityState(session);
if (activityState !== "exited") {
if (activityState?.state !== "exited") {
aliveActivityState = activityState;
break;
}
@ -152,7 +152,7 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => {
});
it("getActivityState → returns exited after agent process terminates", () => {
expect(exitedActivityState).toBe("exited");
expect(exitedActivityState?.state).toBe("exited");
});
it("getSessionInfo → null (not implemented for opencode)", () => {

View File

@ -15,7 +15,7 @@ import {
type WorkspaceHooksConfig,
} from "@composio/ao-core";
import { execFile } from "node:child_process";
import { readdir, readFile, stat, writeFile, mkdir, chmod } from "node:fs/promises";
import { readdir, readFile, stat, open, writeFile, mkdir, chmod } from "node:fs/promises";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { basename, join } from "node:path";
@ -254,21 +254,38 @@ interface JsonlLine {
* Now uses the shared readLastJsonlEntry utility from @composio/ao-core.
*/
/** Parse JSONL file into lines (skipping invalid JSON) */
async function parseJsonlFile(filePath: string): Promise<JsonlLine[]> {
/**
* Parse only the last `maxBytes` of a JSONL file.
* Summaries and recent activity are always near the end, so reading the whole
* file (which can be 100MB+) is wasteful. The first line is skipped since it
* may be truncated at the read boundary.
*/
async function parseJsonlFileTail(filePath: string, maxBytes = 131_072): Promise<JsonlLine[]> {
let content: string;
try {
content = await readFile(filePath, "utf-8");
const handle = await open(filePath, "r");
try {
const { size } = await handle.stat();
const offset = Math.max(0, size - maxBytes);
const length = size - offset;
const buffer = Buffer.allocUnsafe(length);
await handle.read(buffer, 0, length, offset);
content = buffer.toString("utf-8");
} finally {
await handle.close();
}
} catch {
return [];
}
// Skip potentially truncated first line
const firstNewline = content.indexOf("\n");
const safeContent = firstNewline >= 0 ? content.slice(firstNewline + 1) : content;
const lines: JsonlLine[] = [];
for (const line of content.split("\n")) {
for (const line of safeContent.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed: unknown = JSON.parse(trimmed);
// Skip non-object values (null, numbers, strings, arrays)
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
lines.push(parsed as JsonlLine);
}
@ -685,17 +702,8 @@ function createClaudeCodeAgent(): Agent {
const sessionFile = await findLatestSessionFile(projectDir);
if (!sessionFile) return null;
// Get file modification time
let lastLogModified: Date | undefined;
try {
const fileStat = await stat(sessionFile);
lastLogModified = fileStat.mtime;
} catch {
// Ignore stat errors
}
// Parse the JSONL
const lines = await parseJsonlFile(sessionFile);
// Parse only the tail — summaries are always near the end, files can be 100MB+
const lines = await parseJsonlFileTail(sessionFile);
if (lines.length === 0) return null;
// Extract session ID from filename