diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index 17a322be8..33a6a5445 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -77,6 +77,7 @@ beforeEach(() => { getLaunchCommand: vi.fn(), getEnvironment: vi.fn(), detectActivity: vi.fn().mockReturnValue("active" as ActivityState), + getActivityState: vi.fn().mockResolvedValue("active" as ActivityState), isProcessRunning: vi.fn().mockResolvedValue(true), isProcessing: vi.fn().mockResolvedValue(false), getSessionInfo: vi.fn().mockResolvedValue(null), diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index b76e35d55..2d8b5cc4d 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -45,7 +45,8 @@ beforeEach(() => { processName: "mock", getLaunchCommand: vi.fn().mockReturnValue("mock-agent --start"), getEnvironment: vi.fn().mockReturnValue({ AGENT_VAR: "1" }), - detectActivity: vi.fn().mockResolvedValue("active"), + detectActivity: vi.fn().mockReturnValue("active"), + getActivityState: vi.fn().mockResolvedValue("active"), isProcessRunning: vi.fn().mockResolvedValue(true), isProcessing: vi.fn().mockResolvedValue(false), getSessionInfo: vi.fn().mockResolvedValue(null), @@ -395,6 +396,69 @@ describe("list", () => { expect(sessions[0].status).toBe("killed"); expect(sessions[0].activity).toBe("exited"); }); + + it("detects activity using agent-native mechanism", async () => { + const agentWithState: Agent = { + ...mockAgent, + getActivityState: vi.fn().mockResolvedValue("active"), + }; + const registryWithState: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return agentWithState; + return null; + }), + }; + + writeMetadata(dataDir, "app-1", { + worktree: "/tmp", + branch: "a", + status: "working", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sm = createSessionManager({ + config, + registry: registryWithState, + }); + const sessions = await sm.list(); + + // Verify getActivityState was called + expect(agentWithState.getActivityState).toHaveBeenCalled(); + // Verify activity state was set + expect(sessions[0].activity).toBe("active"); + }); + + it("falls back to idle on getActivityState error", async () => { + const agentWithError: Agent = { + ...mockAgent, + getActivityState: vi.fn().mockRejectedValue(new Error("detection failed")), + }; + const registryWithError: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return agentWithError; + return null; + }), + }; + + writeMetadata(dataDir, "app-1", { + worktree: "/tmp", + branch: "a", + status: "working", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sm = createSessionManager({ config, registry: registryWithError }); + const sessions = await sm.list(); + + // Should fall back to idle when getActivityState fails + expect(sessions[0].activity).toBe("idle"); + }); }); describe("get", () => { @@ -417,6 +481,40 @@ describe("get", () => { expect(session!.pr!.url).toBe("https://github.com/org/repo/pull/42"); }); + it("detects activity using agent-native mechanism", async () => { + const agentWithState: Agent = { + ...mockAgent, + getActivityState: vi.fn().mockResolvedValue("idle"), + }; + const registryWithState: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return agentWithState; + return null; + }), + }; + + writeMetadata(dataDir, "app-1", { + worktree: "/tmp", + branch: "main", + status: "working", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sm = createSessionManager({ + config, + registry: registryWithState, + }); + const session = await sm.get("app-1"); + + // Verify getActivityState was called + expect(agentWithState.getActivityState).toHaveBeenCalled(); + // Verify activity state was set + expect(session!.activity).toBe("idle"); + }); + it("returns null for nonexistent session", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); expect(await sm.get("nonexistent")).toBeNull(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0bca88817..4a98f5c99 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -53,4 +53,9 @@ export { generateOrchestratorPrompt } from "./orchestrator-prompt.js"; export type { OrchestratorPromptConfig } from "./orchestrator-prompt.js"; // Shared utilities -export { shellEscape, escapeAppleScript, validateUrl } from "./utils.js"; +export { + shellEscape, + escapeAppleScript, + validateUrl, + readLastJsonlEntry, +} from "./utils.js"; diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 805dea4a9..02ab3d979 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -164,6 +164,37 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { return { runtime, agent, workspace, tracker, scm }; } + /** + * Enrich session with live runtime state (alive/exited) and activity detection. + * Mutates the session object in place. + */ + async function enrichSessionWithRuntimeState( + session: Session, + plugins: ReturnType, + ): Promise { + if (!session.runtimeHandle) return; + + if (plugins.runtime) { + try { + const alive = await plugins.runtime.isAlive(session.runtimeHandle); + if (!alive) { + session.status = "killed"; + session.activity = "exited"; + } else if (plugins.agent) { + // Runtime is alive — detect activity using agent-native mechanism + try { + session.activity = await plugins.agent.getActivityState(session); + } catch { + // Can't detect activity — explicitly set to idle + session.activity = "idle"; + } + } + } catch { + // Can't check — assume still alive + } + } + } + // Define methods as local functions so `this` is not needed async function spawn(spawnConfig: SessionSpawnConfig): Promise { const project = config.projects[spawnConfig.projectId]; @@ -408,22 +439,12 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { const session = metadataToSession(sid, raw, createdAt, modifiedAt); - // Check if runtime is still alive + // Enrich with live runtime state and activity detection if (session.runtimeHandle) { const project = config.projects[session.projectId]; if (project) { const plugins = resolvePlugins(project); - if (plugins.runtime) { - try { - const alive = await plugins.runtime.isAlive(session.runtimeHandle); - if (!alive) { - session.status = "killed"; - session.activity = "exited"; - } - } catch { - // Can't check — assume still alive - } - } + await enrichSessionWithRuntimeState(session, plugins); } } @@ -451,22 +472,12 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { const session = metadataToSession(sessionId, raw, createdAt, modifiedAt); - // Check if runtime is still alive (same as list() method) + // Enrich with live runtime state and activity detection if (session.runtimeHandle) { const project = config.projects[session.projectId]; if (project) { const plugins = resolvePlugins(project); - if (plugins.runtime) { - try { - const alive = await plugins.runtime.isAlive(session.runtimeHandle); - if (!alive) { - session.status = "killed"; - session.activity = "exited"; - } - } catch { - // Can't check — assume still alive - } - } + await enrichSessionWithRuntimeState(session, plugins); } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e7ca05e90..6349a1176 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -193,9 +193,18 @@ export interface Agent { /** Get environment variables for the agent process */ getEnvironment(config: AgentLaunchConfig): Record; - /** Detect what the agent is currently doing from terminal output */ + /** + * Detect what the agent is currently doing from terminal output. + * @deprecated Use getActivityState() instead - this uses hacky terminal parsing. + */ detectActivity(terminalOutput: string): ActivityState; + /** + * Get current activity state using agent-native mechanism (JSONL, SQLite, etc.). + * This is the preferred method for activity detection. + */ + getActivityState(session: Session): Promise; + /** Check if agent process is running (given runtime handle) */ isProcessRunning(handle: RuntimeHandle): Promise; diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index ee52ecddb..61899f46c 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -2,6 +2,8 @@ * Shared utility functions for agent-orchestrator plugins. */ +import { open } from "node:fs/promises"; + /** * POSIX-safe shell escaping: wraps value in single quotes, * escaping any embedded single quotes as '\\'' . @@ -29,3 +31,62 @@ export function validateUrl(url: string, label: string): void { throw new Error(`[${label}] Invalid url: must be http(s), got "${url}"`); } } + +/** + * Read only the last 4KB of JSONL files to find the last entry. + * Avoids loading entire (potentially large) files into memory. + */ +const TAIL_READ_BYTES = 4096; + +/** + * Read the last entry from a JSONL file efficiently. + * Only reads the last TAIL_READ_BYTES (4KB) from the file to avoid loading + * entire potentially large files into memory. + * + * @param filePath - Path to the JSONL file + * @returns Object containing the last entry's type and file mtime, or null if file is empty/invalid + */ +export async function readLastJsonlEntry( + filePath: string, +): Promise<{ lastType: string | null; modifiedAt: Date } | null> { + let fh; + try { + fh = await open(filePath, "r"); + const fileStat = await fh.stat(); + const size = fileStat.size; + if (size === 0) return null; + + // Read only the last TAIL_READ_BYTES (4KB) from the file + const readSize = Math.min(TAIL_READ_BYTES, size); + const buffer = Buffer.alloc(readSize); + const { bytesRead } = await fh.read(buffer, 0, readSize, size - readSize); + + const chunk = buffer.toString("utf-8", 0, bytesRead); + // Walk backwards through lines to find the last valid JSON object with a type + const lines = chunk.split("\n"); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (!line) continue; + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + const obj = parsed as Record; + if (typeof obj.type === "string") { + return { lastType: obj.type, modifiedAt: fileStat.mtime }; + } + } + } catch { + // Skip malformed lines (possibly truncated first line in our chunk) + } + } + + // No entry with a type field found + return { lastType: null, modifiedAt: fileStat.mtime }; + } catch { + return null; + } finally { + await fh?.close(); + } +} diff --git a/packages/integration-tests/src/agent-aider.integration.test.ts b/packages/integration-tests/src/agent-aider.integration.test.ts index b4784f490..2c33a71fd 100644 --- a/packages/integration-tests/src/agent-aider.integration.test.ts +++ b/packages/integration-tests/src/agent-aider.integration.test.ts @@ -17,7 +17,7 @@ import { promisify } from "node:util"; import type { ActivityState, AgentSessionInfo } from "@composio/ao-core"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import aiderPlugin from "@composio/ao-plugin-agent-aider"; -import { isTmuxAvailable, killSessionsByPrefix, createSession, killSession, capturePane } from "./helpers/tmux.js"; +import { isTmuxAvailable, killSessionsByPrefix, createSession, killSession } from "./helpers/tmux.js"; import { pollUntilEqual, sleep } from "./helpers/polling.js"; import { makeTmuxHandle, makeSession } from "./helpers/session-factory.js"; @@ -86,13 +86,13 @@ describe.skipIf(!canRun)("agent-aider (integration)", () => { const sessionName = `${SESSION_PREFIX}${Date.now()}`; let tmpDir: string; - // Observations captured while the agent is alive (atomically) + // Observations captured while the agent is alive let aliveRunning = false; - let aliveActivity: ActivityState | undefined; + let aliveActivityState: ActivityState | undefined; // Observations captured after the agent exits let exitedRunning: boolean; - let exitedActivity: ActivityState; + let exitedActivityState: ActivityState; let sessionInfo: AgentSessionInfo | null; beforeAll(async () => { @@ -107,20 +107,19 @@ describe.skipIf(!canRun)("agent-aider (integration)", () => { const handle = makeTmuxHandle(sessionName); const session = makeSession("inttest-aider", handle, tmpDir); - // Atomically capture "alive" observations. Aider has ~5s Python startup. - const deadline = Date.now() + 25_000; + // Poll until we observe the agent is running. Aider has ~5s Python startup. + const deadline = Date.now() + 30_000; while (Date.now() < deadline) { const running = await agent.isProcessRunning(handle); if (running) { aliveRunning = true; - const output = await capturePane(sessionName); - const activity = agent.detectActivity(output); - if (activity !== "exited") { - aliveActivity = activity; + const activityState = await agent.getActivityState(session); + if (activityState !== "exited") { + aliveActivityState = activityState; break; } } - await sleep(500); + await sleep(1_000); } // Wait for agent to exit — aider with --message should exit after responding @@ -130,10 +129,9 @@ describe.skipIf(!canRun)("agent-aider (integration)", () => { { timeoutMs: 90_000, intervalMs: 2_000 }, ); - const exitedOutput = await capturePane(sessionName); - exitedActivity = agent.detectActivity(exitedOutput); + exitedActivityState = await agent.getActivityState(session); sessionInfo = await agent.getSessionInfo(session); - }, 120_000); + }, 150_000); afterAll(async () => { await killSession(sessionName); @@ -146,10 +144,11 @@ describe.skipIf(!canRun)("agent-aider (integration)", () => { expect(aliveRunning).toBe(true); }); - it("detectActivity → not exited while agent is alive", () => { - if (aliveActivity !== undefined) { - expect(aliveActivity).not.toBe("exited"); - expect(["active", "idle", "waiting_input", "blocked"]).toContain(aliveActivity); + it("getActivityState → returns valid state while agent is running", () => { + // Aider checks git commits and chat history mtime for activity detection + if (aliveActivityState !== undefined) { + expect(aliveActivityState).not.toBe("exited"); + expect(["active", "idle", "waiting_input", "blocked"]).toContain(aliveActivityState); } }); @@ -157,12 +156,8 @@ describe.skipIf(!canRun)("agent-aider (integration)", () => { expect(exitedRunning).toBe(false); }); - it("detectActivity → idle or active after agent exits", () => { - // Aider's stub classifier returns "active" for any non-empty output and - // "idle" for empty output. After exit the terminal usually shows a shell - // prompt (non-empty → "active"), but may be empty depending on timing. - // Process exit is detected by isProcessRunning, not detectActivity. - expect(["idle", "active"]).toContain(exitedActivity); + it("getActivityState → returns exited after agent process terminates", () => { + expect(exitedActivityState).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 8b050416c..e986b8ac0 100644 --- a/packages/integration-tests/src/agent-claude-code.integration.test.ts +++ b/packages/integration-tests/src/agent-claude-code.integration.test.ts @@ -17,7 +17,7 @@ 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 { isTmuxAvailable, killSessionsByPrefix, createSession, killSession, capturePane } from "./helpers/tmux.js"; +import { isTmuxAvailable, killSessionsByPrefix, createSession, killSession } from "./helpers/tmux.js"; import { pollUntilEqual, sleep } from "./helpers/polling.js"; import { makeTmuxHandle, makeSession } from "./helpers/session-factory.js"; @@ -55,14 +55,15 @@ describe.skipIf(!canRun)("agent-claude-code (integration)", () => { const sessionName = `${SESSION_PREFIX}${Date.now()}`; let tmpDir: string; - // Observations captured while the agent is alive (atomically) + // Observations captured while the agent is alive let aliveRunning = false; - let aliveActivity: ActivityState | undefined; + let aliveActivityState: ActivityState | undefined; + let aliveSessionInfo: AgentSessionInfo | null = null; // Observations captured after the agent exits let exitedRunning: boolean; - let exitedActivity: ActivityState; - let sessionInfo: AgentSessionInfo | null; + let exitedActivityState: ActivityState; + let exitedSessionInfo: AgentSessionInfo | null; beforeAll(async () => { await killSessionsByPrefix(SESSION_PREFIX); @@ -71,40 +72,46 @@ describe.skipIf(!canRun)("agent-claude-code (integration)", () => { const raw = await mkdtemp(join(tmpdir(), "ao-inttest-claude-")); tmpDir = await realpath(raw); - // Spawn Claude with a trivial prompt - const cmd = `CLAUDECODE= ${claudeBin} -p 'Say hello and nothing else'`; + // Spawn Claude with a task that generates observable activity (file creation) + const cmd = `CLAUDECODE= ${claudeBin} -p 'Create a file called test.txt with the content "integration test"'`; await createSession(sessionName, cmd, tmpDir); const handle = makeTmuxHandle(sessionName); const session = makeSession("inttest-claude", handle, tmpDir); - // Atomically capture "alive" observations - const deadline = Date.now() + 15_000; + // Poll until we capture "alive" observations + // Claude needs time to start, create JSONL, and begin processing + const deadline = Date.now() + 30_000; while (Date.now() < deadline) { const running = await agent.isProcessRunning(handle); if (running) { aliveRunning = true; - const output = await capturePane(sessionName); - const activity = agent.detectActivity(output); - if (activity !== "exited") { - aliveActivity = activity; - break; + try { + const activityState = await agent.getActivityState(session); + if (activityState !== "exited") { + aliveActivityState = activityState; + // Also capture session info while alive + aliveSessionInfo = await agent.getSessionInfo(session); + break; + } + } catch { + // JSONL might not exist yet, keep polling } } - await sleep(500); + await sleep(1_000); } - // Wait for agent to exit (trivial prompt should complete quickly) + // Wait for agent to exit (simple task should complete within 90s) exitedRunning = await pollUntilEqual( () => agent.isProcessRunning(handle), false, { timeoutMs: 90_000, intervalMs: 2_000 }, ); - const exitedOutput = await capturePane(sessionName); - exitedActivity = agent.detectActivity(exitedOutput); - sessionInfo = await agent.getSessionInfo(session); - }, 120_000); + // Capture post-exit observations + exitedActivityState = await agent.getActivityState(session); + exitedSessionInfo = await agent.getSessionInfo(session); + }, 150_000); afterAll(async () => { await killSession(sessionName); @@ -117,10 +124,20 @@ describe.skipIf(!canRun)("agent-claude-code (integration)", () => { expect(aliveRunning).toBe(true); }); - it("detectActivity → not exited while agent is alive", () => { - if (aliveActivity !== undefined) { - expect(aliveActivity).not.toBe("exited"); - expect(["active", "idle", "waiting_input", "blocked"]).toContain(aliveActivity); + it("getActivityState → returns valid non-exited state while agent is alive", () => { + expect(aliveActivityState).toBeDefined(); + expect(aliveActivityState).not.toBe("exited"); + expect(["active", "idle", "waiting_input", "blocked"]).toContain(aliveActivityState); + }); + + it("getSessionInfo → returns session data while agent is alive (or null if path mismatch)", () => { + // The JSONL path depends on Claude's internal encoding of workspacePath. + // If the temp dir resolves differently (symlinks, etc.), may return null. + // Both outcomes are acceptable — the key is it doesn't throw. + if (aliveSessionInfo !== null) { + expect(aliveSessionInfo).toHaveProperty("summary"); + expect(aliveSessionInfo).toHaveProperty("agentSessionId"); + expect(typeof aliveSessionInfo.agentSessionId).toBe("string"); } }); @@ -128,20 +145,17 @@ describe.skipIf(!canRun)("agent-claude-code (integration)", () => { expect(exitedRunning).toBe(false); }); - it("detectActivity → idle after agent exits", () => { - // detectActivity is a pure terminal-text classifier; it returns "idle" - // for empty/shell-prompt output. Process exit is detected by isProcessRunning. - expect(exitedActivity).toBe("idle"); + it("getActivityState → returns exited after agent process terminates", () => { + expect(exitedActivityState).toBe("exited"); }); - it("getSessionInfo → returns session data (or null if JSONL path mismatch)", () => { - // The JSONL path depends on Claude's internal encoding of workspacePath. - // If the temp dir path resolves differently, getSessionInfo may return null. + it("getSessionInfo → returns session data after agent exits (or null if path mismatch)", () => { + // JSONL should still be readable after exit, but path encoding may cause null. // Both outcomes are acceptable — the key is it doesn't throw. - if (sessionInfo !== null) { - expect(sessionInfo).toHaveProperty("summary"); - expect(sessionInfo).toHaveProperty("agentSessionId"); - expect(typeof sessionInfo.agentSessionId).toBe("string"); + if (exitedSessionInfo !== null) { + expect(exitedSessionInfo).toHaveProperty("summary"); + expect(exitedSessionInfo).toHaveProperty("agentSessionId"); + expect(typeof exitedSessionInfo.agentSessionId).toBe("string"); } }); }); diff --git a/packages/integration-tests/src/agent-codex.integration.test.ts b/packages/integration-tests/src/agent-codex.integration.test.ts index b346d4b71..e02ed8c1d 100644 --- a/packages/integration-tests/src/agent-codex.integration.test.ts +++ b/packages/integration-tests/src/agent-codex.integration.test.ts @@ -17,7 +17,7 @@ import { promisify } from "node:util"; import type { ActivityState, AgentSessionInfo } from "@composio/ao-core"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import codexPlugin from "@composio/ao-plugin-agent-codex"; -import { isTmuxAvailable, killSessionsByPrefix, createSession, killSession, capturePane } from "./helpers/tmux.js"; +import { isTmuxAvailable, killSessionsByPrefix, createSession, killSession } from "./helpers/tmux.js"; import { pollUntilEqual, sleep } from "./helpers/polling.js"; import { makeTmuxHandle, makeSession } from "./helpers/session-factory.js"; @@ -55,13 +55,13 @@ describe.skipIf(!canRun)("agent-codex (integration)", () => { const sessionName = `${SESSION_PREFIX}${Date.now()}`; let tmpDir: string; - // Observations captured while the agent is alive (atomically) + // Observations captured while the agent is alive let aliveRunning = false; - let aliveActivity: ActivityState | undefined; + let aliveActivityState: ActivityState | undefined; // Observations captured after the agent exits let exitedRunning: boolean; - let exitedActivity: ActivityState; + let exitedActivityState: ActivityState; let sessionInfo: AgentSessionInfo | null; beforeAll(async () => { @@ -74,23 +74,19 @@ describe.skipIf(!canRun)("agent-codex (integration)", () => { const handle = makeTmuxHandle(sessionName); const session = makeSession("inttest-codex", handle, tmpDir); - // Atomically capture "alive" observations — poll until we observe - // both running=true AND activity!="exited" in the same iteration. - // Fast-exiting agents may exit between separate calls, so we must - // check both in a tight loop. + // Poll until we observe the agent is running and capture activity state const deadline = Date.now() + 15_000; while (Date.now() < deadline) { const running = await agent.isProcessRunning(handle); if (running) { aliveRunning = true; - const output = await capturePane(sessionName); - const activity = agent.detectActivity(output); - if (activity !== "exited") { - aliveActivity = activity; + const activityState = await agent.getActivityState(session); + if (activityState !== "exited") { + aliveActivityState = activityState; break; } } - await sleep(200); + await sleep(500); } // Wait for agent to exit @@ -100,8 +96,7 @@ describe.skipIf(!canRun)("agent-codex (integration)", () => { { timeoutMs: 90_000, intervalMs: 2_000 }, ); - const exitedOutput = await capturePane(sessionName); - exitedActivity = agent.detectActivity(exitedOutput); + exitedActivityState = await agent.getActivityState(session); sessionInfo = await agent.getSessionInfo(session); }, 120_000); @@ -116,12 +111,11 @@ describe.skipIf(!canRun)("agent-codex (integration)", () => { expect(aliveRunning).toBe(true); }); - it("detectActivity → not exited while agent is alive", () => { - // For very fast agents, we may not catch a non-exited activity state. - // If aliveActivity is undefined, the agent exited too fast for atomic capture. - if (aliveActivity !== undefined) { - expect(aliveActivity).not.toBe("exited"); - expect(["active", "idle", "waiting_input", "blocked"]).toContain(aliveActivity); + it("getActivityState → returns active while agent is running", () => { + // Codex uses conservative fallback: returns "active" when process is running + // (due to global rollout file storage without per-session scoping) + if (aliveActivityState !== undefined) { + expect(aliveActivityState).toBe("active"); } }); @@ -129,12 +123,8 @@ describe.skipIf(!canRun)("agent-codex (integration)", () => { expect(exitedRunning).toBe(false); }); - it("detectActivity → idle or active after agent exits", () => { - // Codex's stub classifier returns "active" for any non-empty output and - // "idle" for empty output. After exit the terminal usually shows a shell - // prompt (non-empty → "active"), but may be empty depending on timing. - // Process exit is detected by isProcessRunning, not detectActivity. - expect(["idle", "active"]).toContain(exitedActivity); + it("getActivityState → returns exited after agent process terminates", () => { + expect(exitedActivityState).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 52ddf3751..01f8e7ef7 100644 --- a/packages/integration-tests/src/agent-opencode.integration.test.ts +++ b/packages/integration-tests/src/agent-opencode.integration.test.ts @@ -17,7 +17,7 @@ import { promisify } from "node:util"; import type { ActivityState, AgentSessionInfo } from "@composio/ao-core"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import opencodePlugin from "@composio/ao-plugin-agent-opencode"; -import { isTmuxAvailable, killSessionsByPrefix, createSession, killSession, capturePane } from "./helpers/tmux.js"; +import { isTmuxAvailable, killSessionsByPrefix, createSession, killSession } from "./helpers/tmux.js"; import { pollUntilEqual, sleep } from "./helpers/polling.js"; import { makeTmuxHandle, makeSession } from "./helpers/session-factory.js"; @@ -79,13 +79,13 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => { const sessionName = `${SESSION_PREFIX}${Date.now()}`; let tmpDir: string; - // Observations captured while the agent is alive (atomically) + // Observations captured while the agent is alive let aliveRunning = false; - let aliveActivity: ActivityState | undefined; + let aliveActivityState: ActivityState | undefined; // Observations captured after the agent exits let exitedRunning: boolean; - let exitedActivity: ActivityState; + let exitedActivityState: ActivityState; let sessionInfo: AgentSessionInfo | null; beforeAll(async () => { @@ -98,20 +98,19 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => { const handle = makeTmuxHandle(sessionName); const session = makeSession("inttest-opencode", handle, tmpDir); - // Atomically capture "alive" observations + // Poll until we observe the agent is running and capture activity state const deadline = Date.now() + 15_000; while (Date.now() < deadline) { const running = await agent.isProcessRunning(handle); if (running) { aliveRunning = true; - const output = await capturePane(sessionName); - const activity = agent.detectActivity(output); - if (activity !== "exited") { - aliveActivity = activity; + const activityState = await agent.getActivityState(session); + if (activityState !== "exited") { + aliveActivityState = activityState; break; } } - await sleep(200); + await sleep(500); } // Wait for agent to exit @@ -121,8 +120,7 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => { { timeoutMs: 90_000, intervalMs: 2_000 }, ); - const exitedOutput = await capturePane(sessionName); - exitedActivity = agent.detectActivity(exitedOutput); + exitedActivityState = await agent.getActivityState(session); sessionInfo = await agent.getSessionInfo(session); }, 120_000); @@ -137,10 +135,11 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => { expect(aliveRunning).toBe(true); }); - it("detectActivity → not exited while agent is alive", () => { - if (aliveActivity !== undefined) { - expect(aliveActivity).not.toBe("exited"); - expect(["active", "idle", "waiting_input", "blocked"]).toContain(aliveActivity); + it("getActivityState → returns active while agent is running", () => { + // OpenCode uses conservative fallback: returns "active" when process is running + // (due to global SQLite database shared by all sessions) + if (aliveActivityState !== undefined) { + expect(aliveActivityState).toBe("active"); } }); @@ -148,12 +147,8 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => { expect(exitedRunning).toBe(false); }); - it("detectActivity → idle or active after agent exits", () => { - // OpenCode's stub classifier returns "active" for any non-empty output and - // "idle" for empty output. After exit the terminal usually shows a shell - // prompt (non-empty → "active"), but may be empty depending on timing. - // Process exit is detected by isProcessRunning, not detectActivity. - expect(["idle", "active"]).toContain(exitedActivity); + it("getActivityState → returns exited after agent process terminates", () => { + expect(exitedActivityState).toBe("exited"); }); it("getSessionInfo → null (not implemented for opencode)", () => { diff --git a/packages/plugins/agent-aider/src/index.ts b/packages/plugins/agent-aider/src/index.ts index 7b4eff015..5c40066da 100644 --- a/packages/plugins/agent-aider/src/index.ts +++ b/packages/plugins/agent-aider/src/index.ts @@ -10,9 +10,46 @@ import { } from "@composio/ao-core"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; +import { stat, access } from "node:fs/promises"; +import { join } from "node:path"; +import { constants } from "node:fs"; const execFileAsync = promisify(execFile); +// ============================================================================= +// Aider Activity Detection Helpers +// ============================================================================= + +/** + * Check if Aider has made recent commits (within last 60 seconds). + */ +async function hasRecentCommits(workspacePath: string): Promise { + try { + const { stdout } = await execFileAsync( + "git", + ["log", "--since=60 seconds ago", "--format=%H"], + { cwd: workspacePath, timeout: 5_000 }, + ); + return stdout.trim().length > 0; + } catch { + return false; + } +} + +/** + * Get modification time of Aider chat history file. + */ +async function getChatHistoryMtime(workspacePath: string): Promise { + try { + const chatFile = join(workspacePath, ".aider.chat.history.md"); + await access(chatFile, constants.R_OK); + const stats = await stat(chatFile); + return stats.mtime; + } catch { + return null; + } +} + // ============================================================================= // Plugin Manifest // ============================================================================= @@ -67,6 +104,34 @@ function createAiderAgent(): Agent { return "active"; }, + async getActivityState(session: Session): Promise { + // Check if process is running first + if (!session.runtimeHandle) return "exited"; + const running = await this.isProcessRunning(session.runtimeHandle); + if (!running) return "exited"; + + // Process is running - check for activity signals + if (!session.workspacePath) return "active"; + + // Check for recent git commits (Aider auto-commits changes) + const hasCommits = await hasRecentCommits(session.workspacePath); + if (hasCommits) return "active"; + + // Check chat history file modification time + const chatMtime = await getChatHistoryMtime(session.workspacePath); + if (!chatMtime) { + // No chat history yet, but process is running - assume active + return "active"; + } + + // If chat file was modified within last 30 seconds, consider active + const ageMs = Date.now() - chatMtime.getTime(); + if (ageMs < 30_000) return "active"; + + // No recent activity - idle at prompt + return "idle"; + }, + async isProcessRunning(handle: RuntimeHandle): Promise { try { if (handle.runtimeName === "tmux" && handle.id) { diff --git a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts new file mode 100644 index 000000000..c8b668abf --- /dev/null +++ b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { toClaudeProjectPath } from "../index.js"; + +describe("Claude Code Activity Detection", () => { + describe("toClaudeProjectPath", () => { + it("encodes paths correctly", () => { + expect(toClaudeProjectPath("/Users/dev/.worktrees/ao")).toBe( + "Users-dev--worktrees-ao", + ); + }); + + it("strips leading slash", () => { + expect(toClaudeProjectPath("/tmp/test")).toBe("tmp-test"); + }); + + it("replaces dots", () => { + expect(toClaudeProjectPath("/path/to/.hidden")).toBe("path-to--hidden"); + }); + + it("handles Windows paths", () => { + expect(toClaudeProjectPath("C:\\Users\\dev\\project")).toBe( + "C-Users-dev-project", + ); + }); + }); + + // NOTE: Full integration tests for getActivityState() require mocking homedir() + // or using a real Claude Code installation with actual session files. + // For now, we test the path encoding logic which is the core transformation. + // End-to-end testing should be done manually or with a real Claude Code instance. +}); diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 3dd6e9daf..4709ac003 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -1,5 +1,6 @@ import { shellEscape, + readLastJsonlEntry, type Agent, type AgentSessionInfo, type AgentLaunchConfig, @@ -11,7 +12,7 @@ import { type WorkspaceHooksConfig, } from "@composio/ao-core"; import { execFile } from "node:child_process"; -import { open, readdir, readFile, stat, writeFile, mkdir, chmod } from "node:fs/promises"; +import { readdir, readFile, stat, writeFile, mkdir, chmod } from "node:fs/promises"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { basename, join } from "node:path"; @@ -193,8 +194,10 @@ export const manifest = { * If Claude Code changes its encoding scheme this will silently break * introspection. The path can be validated at runtime by checking whether * the resulting directory exists. + * + * Exported for testing purposes. */ -function toClaudeProjectPath(workspacePath: string): string { +export function toClaudeProjectPath(workspacePath: string): string { // Handle Windows drive letters (C:\Users\... → C-Users-...) const normalized = workspacePath.replace(/\\/g, "/"); return normalized.replace(/^\//, "").replace(/:/g, "").replace(/[/.]/g, "-"); @@ -249,51 +252,8 @@ interface JsonlLine { * Read only the last chunk of a JSONL file to extract the last entry's type * and the file's modification time. This is optimized for polling — it avoids * reading the entire file (which `getSessionInfo()` does for full cost/summary). + * Now uses the shared readLastJsonlEntry utility from @composio/ao-core. */ -const TAIL_READ_BYTES = 4096; - -async function readLastJsonlEntry( - filePath: string, -): Promise<{ lastType: string | null; modifiedAt: Date } | null> { - let fh; - try { - fh = await open(filePath, "r"); - const fileStat = await fh.stat(); - const size = fileStat.size; - if (size === 0) return null; - - const readSize = Math.min(TAIL_READ_BYTES, size); - const buffer = Buffer.alloc(readSize); - const { bytesRead } = await fh.read(buffer, 0, readSize, size - readSize); - - const chunk = buffer.toString("utf-8", 0, bytesRead); - // Walk backwards through lines to find the last valid JSON object with a type - const lines = chunk.split("\n"); - for (let i = lines.length - 1; i >= 0; i--) { - const line = lines[i]; - if (!line) continue; - const trimmed = line.trim(); - if (!trimmed) continue; - try { - const parsed: unknown = JSON.parse(trimmed); - if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { - const obj = parsed as Record; - if (typeof obj.type === "string") { - return { lastType: obj.type, modifiedAt: fileStat.mtime }; - } - } - } catch { - // Skip malformed lines (possibly truncated first line in our chunk) - } - } - - return { lastType: null, modifiedAt: fileStat.mtime }; - } catch { - return null; - } finally { - await fh?.close(); - } -} /** Parse JSONL file into lines (skipping invalid JSON) */ async function parseJsonlFile(filePath: string): Promise { @@ -673,6 +633,63 @@ function createClaudeCodeAgent(): Agent { return true; }, + async getActivityState(session: Session): Promise { + // Check if process is running first + if (!session.runtimeHandle) return "exited"; + const running = await this.isProcessRunning(session.runtimeHandle); + if (!running) return "exited"; + + // Process is running - check JSONL session file for activity + if (!session.workspacePath) { + // No workspace path but process is running - assume active + return "active"; + } + + const projectPath = toClaudeProjectPath(session.workspacePath); + const projectDir = join(homedir(), ".claude", "projects", projectPath); + + const sessionFile = await findLatestSessionFile(projectDir); + if (!sessionFile) { + // No session file found yet, but process is running - assume active + return "active"; + } + + const entry = await readLastJsonlEntry(sessionFile); + if (!entry) { + // Empty file or read error, but process is running - assume active + return "active"; + } + + // Check staleness - no activity in 30 seconds means idle + const ageMs = Date.now() - entry.modifiedAt.getTime(); + if (ageMs > 30_000) return "idle"; + + // Classify based on last JSONL entry type + switch (entry.lastType) { + case "user": + case "tool_use": + // Agent is processing user request or running tools + return "active"; + + case "assistant": + case "system": + // Agent finished its turn, waiting for user input + return "idle"; + + case "permission_request": + // Agent needs user approval for an action + return "waiting_input"; + + case "error": + // Agent encountered an error + return "blocked"; + + default: + // Unknown type, assume active + return "active"; + } + }, + async getSessionInfo(session: Session): Promise { if (!session.workspacePath) return null; diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index b04c640f0..b21d9bbf8 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -69,6 +69,23 @@ function createCodexAgent(): Agent { return "active"; }, + async getActivityState(session: Session): Promise { + // Check if process is running first + if (!session.runtimeHandle) return "exited"; + const running = await this.isProcessRunning(session.runtimeHandle); + if (!running) return "exited"; + + // NOTE: Codex stores rollout files in a global ~/.codex/sessions/ directory + // without workspace-specific scoping. When multiple Codex sessions run in + // parallel, we cannot reliably determine which rollout file belongs to which + // session. Until Codex provides per-workspace session tracking, we fall back + // to process-running check only. See issue #13 for details. + // + // TODO: Implement proper per-session activity detection when Codex supports it. + // For now, return "active" if process is running (conservative approach). + return "active"; + }, + async isProcessRunning(handle: RuntimeHandle): Promise { try { if (handle.runtimeName === "tmux" && handle.id) { diff --git a/packages/plugins/agent-opencode/src/index.ts b/packages/plugins/agent-opencode/src/index.ts index f9eb239a0..ae4f9a726 100644 --- a/packages/plugins/agent-opencode/src/index.ts +++ b/packages/plugins/agent-opencode/src/index.ts @@ -63,6 +63,23 @@ function createOpenCodeAgent(): Agent { return "active"; }, + async getActivityState(session: Session): Promise { + // Check if process is running first + if (!session.runtimeHandle) return "exited"; + const running = await this.isProcessRunning(session.runtimeHandle); + if (!running) return "exited"; + + // NOTE: OpenCode stores all session data in a single global SQLite database + // at ~/.local/share/opencode/opencode.db without per-workspace scoping. When + // multiple OpenCode sessions run in parallel, database modifications from any + // session will cause all sessions to appear active. Until OpenCode provides + // per-workspace session tracking, we fall back to process-running check only. + // + // TODO: Implement proper per-session activity detection when OpenCode supports it. + // For now, return "active" if process is running (conservative approach). + return "active"; + }, + async isProcessRunning(handle: RuntimeHandle): Promise { try { if (handle.runtimeName === "tmux" && handle.id) {