diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index 0620ca912..0393a4908 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -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, diff --git a/packages/integration-tests/src/agent-aider.integration.test.ts b/packages/integration-tests/src/agent-aider.integration.test.ts index 9dc6182a4..9e8f38c64 100644 --- a/packages/integration-tests/src/agent-aider.integration.test.ts +++ b/packages/integration-tests/src/agent-aider.integration.test.ts @@ -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)", () => { 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 3ebecb339..4e61f7e27 100644 --- a/packages/integration-tests/src/agent-claude-code.integration.test.ts +++ b/packages/integration-tests/src/agent-claude-code.integration.test.ts @@ -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)", () => { diff --git a/packages/integration-tests/src/agent-codex.integration.test.ts b/packages/integration-tests/src/agent-codex.integration.test.ts index 01f57c9b3..7e18aa8db 100644 --- a/packages/integration-tests/src/agent-codex.integration.test.ts +++ b/packages/integration-tests/src/agent-codex.integration.test.ts @@ -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)", () => { diff --git a/packages/integration-tests/src/agent-opencode.integration.test.ts b/packages/integration-tests/src/agent-opencode.integration.test.ts index 7f07d4242..12f714112 100644 --- a/packages/integration-tests/src/agent-opencode.integration.test.ts +++ b/packages/integration-tests/src/agent-opencode.integration.test.ts @@ -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)", () => { diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index d8f7ecef6..8c18d8c19 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -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 { +/** + * 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 { 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