feat: wire up live activity detection for agent sessions (#45)
* feat: wire up live activity detection for agent sessions Previously, all sessions showed as "idle" on the dashboard because activity detection was never called. The Agent plugin interface has detectActivity(terminalOutput) and Runtime has getOutput(), but the session manager's list() and get() methods never invoked them. Changes: - Updated list() to call runtime.getOutput() and agent.detectActivity() after checking if runtime is alive - Updated get() with the same activity detection logic - Captures last 30 lines of terminal output for classification - Falls back to "idle" if output capture fails (graceful degradation) - Agent plugins classify output into: active, idle, waiting_input, blocked, or exited Testing: - Added test for activity detection in list() with mocked output - Added test for activity detection in get() with mocked output - Added test for graceful fallback when getOutput() fails - Fixed detectActivity mock (sync not async) - All 24 tests pass The dashboard now accurately shows whether agents are actively working, idle at prompt, waiting for user input, blocked, or exited. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * refactor: extract enrichSessionWithRuntimeState helper Addresses review comment about duplicated activity detection logic between list() and get() methods. Changes: - Extract activity detection into enrichSessionWithRuntimeState helper - Both list() and get() now call this shared helper - Eliminates duplication and makes future changes easier - All tests still pass (24/24) Benefits: - Single source of truth for runtime state enrichment - Future changes (caching, param tweaks) only need one update - More maintainable and less error-prone Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * refactor: replace terminal parsing with agent-native activity detection Replaces hacky terminal output parsing (detectActivity) with deterministic agent-native state tracking (getActivityState). Each agent now uses its own internal mechanisms (JSONL files, SQLite, etc.) for reliable activity detection. ## Changes ### Core (`packages/core/src/types.ts`) - Add `getActivityState(session)` method to Agent interface - Deprecate `detectActivity(terminalOutput)` with @deprecated tag ### Session Manager (`packages/core/src/session-manager.ts`) - Update `enrichSessionWithRuntimeState()` to call `agent.getActivityState()` - Remove terminal output capture and parsing - Cleaner, more maintainable code ### Claude Code Plugin (`packages/plugins/agent-claude-code/`) - Implement `getActivityState()` using existing JSONL infrastructure - Read last JSONL entry and classify by event type: - user/tool_use → active - assistant/system → idle - permission_request → waiting_input - error → blocked - stale (>30s) → idle - Export `toClaudeProjectPath()` for testing - Add unit tests for path encoding ### Other Agent Plugins (Codex, Aider, OpenCode) - Add stub `getActivityState()` implementations - Fall back to process running check - TODO comments for full implementation using: - Codex: JSONL rollout files - Aider: Chat history mtime - OpenCode: SQLite database ### Tests (`packages/core/src/__tests__/session-manager.test.ts`) - Update tests to expect `getActivityState()` calls - Remove tests for deprecated terminal parsing - All 24 tests pass ✅ ## Benefits ✅ **Deterministic** - File-based, not terminal text parsing ✅ **Fast** - Single file read, no regex ✅ **Reliable** - Agent-provided state ✅ **Testable** - Mock files easily ✅ **Maintainable** - Pin versions, test format changes ## Migration Path - `detectActivity()` is deprecated but not removed (backwards compat) - Claude Code uses new method immediately - Other agents use fallback until fully implemented - Future PR will remove deprecated method ## Research Agent-native mechanisms documented in: - OpenAI Codex: JSONL rollout files at ~/.codex/sessions/ - Aider: Chat history at .aider.chat.history.md - OpenCode: SQLite at ~/.local/share/opencode/opencode.db See /tmp/activity-detection-redesign.md for full design. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: implement native activity detection for all agent plugins Implements full getActivityState() for Codex, Aider, and OpenCode: - **Codex**: Checks JSONL rollout files at ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl Maps event types (user_message, tool_use, approval_request, error) to activity states - **Aider**: Checks chat history file mtime at .aider.chat.history.md and recent git commits Detects activity based on file modification and auto-commit behavior - **OpenCode**: Checks SQLite database mtime at ~/.local/share/opencode/opencode.db Considers active if database was modified within 30 seconds All implementations follow the deterministic approach established for Claude Code, avoiding hacky terminal output parsing in favor of agent-native mechanisms. Also fixes test mocks to include new getActivityState() method. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: address bugbot review comments for activity detection Fixes three issues identified by Cursor Bugbot: 1. **Claude Code returns "exited" when runtime is alive** (Medium) - Added process-alive check at start of getActivityState() - Changed fallback returns from "exited" to "active" when process is running - Now matches pattern used by other agents (Codex, Aider, OpenCode) 2. **Claude Code missing process-alive check** (Medium) - Added isProcessRunning() check before file-based detection - Prevents reporting stale file-based activity when process has exited - Ensures "exited" is only returned when process is actually dead 3. **Codex reads entire JSONL file into memory** (Medium) - Replaced readFile() with seeked file handle read - Now reads only last 4KB (TAIL_READ_BYTES) instead of entire file - Matches efficient approach used by Claude Code plugin - Prevents memory/latency issues on long-running sessions All tests passing (85 for Claude Code, 28 for Codex). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: address three additional bugbot review comments Fixes three issues identified in the second Cursor Bugbot scan: 1. **Catch block doesn't set activity to idle** (Low) - Added explicit `session.activity = "idle"` in catch block - Previously relied on implicit default from metadataToSession - Now explicitly documents and enforces the fallback behavior 2. **Codex rollout file matching uses wrong session ID** (Medium) - Removed session ID filtering from findLatestRolloutFile() - Codex uses internal UUIDs unrelated to orchestrator session IDs - Now finds most recent rollout file across all dates - Fixes non-functional activity detection for Codex 3. **Duplicate readLastJsonlEntry across packages** (Low) - Extracted shared JSONL tail-reading logic to @composio/ao-core/utils - Removed duplicate implementations from agent-claude-code and agent-codex - Both plugins now import readLastJsonlEntry and TAIL_READ_BYTES from core - Net reduction of 21 lines while improving maintainability All 240 tests passing (127 core + 85 Claude Code + 28 Codex). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: remove unused TAIL_READ_BYTES import from claude-code plugin * fix: address third round of bugbot comments - document agent limitations Fixes three issues from the latest Cursor Bugbot scan: 1. **Codex reads global state not per-session** (High Severity) - Removed file-based activity detection from Codex plugin - Codex stores rollout files globally without workspace scoping - When multiple sessions run, cannot reliably match files to sessions - Now falls back to process-running check only (conservative) - Documented limitation with TODO for when Codex adds per-workspace support 2. **OpenCode uses global shared database** (Medium Severity) - Removed database mtime checking from OpenCode plugin - OpenCode uses single global SQLite database for all sessions - Multiple sessions cause activity state bleed - Now falls back to process-running check only (conservative) - Documented limitation with TODO for when OpenCode adds per-workspace support 3. **Unused TAIL_READ_BYTES export** (Low Severity) - Removed TAIL_READ_BYTES from core package exports - Only used internally by readLastJsonlEntry in utils.ts - Reduces unnecessary public API surface Net reduction: 108 lines of code that couldn't work correctly with multiple sessions. All tests passing (28 Codex + 27 OpenCode). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * test: rewrite agent integration tests to use getActivityState() instead of detectActivity() Integration tests were using the deprecated detectActivity() method, which meant they weren't actually testing the new getActivityState() functionality that session-manager uses in production. Changes: - All agent integration tests now call getActivityState() instead of detectActivity() - Tests verify getActivityState() returns valid states while running - Tests verify getActivityState() returns "exited" after process terminates - Grouped multiple assertions per test run for efficiency - Updated comments to reflect new behavior (Codex/OpenCode conservative fallback) - Allow getSessionInfo() to return null for path encoding mismatches All integration tests pass: - agent-claude-code: 6 passed (full JSONL-based activity detection) - agent-codex: 5 passed (conservative fallback) - agent-opencode: 5 passed (conservative fallback) - agent-aider: updated but not tested (aider binary not available in CI) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: remove unused imports from integration tests Fixed ESLint errors: - Removed unused 'capturePane' imports from all agent integration tests - Removed unused 'err' variable from catch block Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: return active instead of idle when JSONL entry is null When readLastJsonlEntry returns null (empty file or read error), getActivityState now returns "active" instead of "idle" as a conservative fallback. This makes it consistent with: - Other fallback cases in the same function (no workspace path, no session file) - Other agent plugins (Codex, OpenCode, Aider) which return "active" on detection failure - The stated intent in PR discussion to assume active when process is running Fixes bugbot comment: https://github.com/ComposioHQ/agent-orchestrator/pull/45#discussion_r2810110669 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: make TAIL_READ_BYTES internal constant instead of exported TAIL_READ_BYTES is only used internally within readLastJsonlEntry() and is not exported from the package's index.ts. Removed the export keyword to make it clear it's an internal implementation detail. Addresses bugbot comment about unused export. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
77323cb309
commit
79aac7cf1d
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<typeof resolvePlugins>,
|
||||
): Promise<void> {
|
||||
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<Session> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -193,9 +193,18 @@ export interface Agent {
|
|||
/** Get environment variables for the agent process */
|
||||
getEnvironment(config: AgentLaunchConfig): Record<string, string>;
|
||||
|
||||
/** 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<ActivityState>;
|
||||
|
||||
/** Check if agent process is running (given runtime handle) */
|
||||
isProcessRunning(handle: RuntimeHandle): Promise<boolean>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
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<Date | null> {
|
||||
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<ActivityState> {
|
||||
// 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<boolean> {
|
||||
try {
|
||||
if (handle.runtimeName === "tmux" && handle.id) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
});
|
||||
|
|
@ -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<string, unknown>;
|
||||
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<JsonlLine[]> {
|
||||
|
|
@ -673,6 +633,63 @@ function createClaudeCodeAgent(): Agent {
|
|||
return true;
|
||||
},
|
||||
|
||||
async getActivityState(session: Session): Promise<ActivityState> {
|
||||
// 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<AgentSessionInfo | null> {
|
||||
if (!session.workspacePath) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,23 @@ function createCodexAgent(): Agent {
|
|||
return "active";
|
||||
},
|
||||
|
||||
async getActivityState(session: Session): Promise<ActivityState> {
|
||||
// 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<boolean> {
|
||||
try {
|
||||
if (handle.runtimeName === "tmux" && handle.id) {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,23 @@ function createOpenCodeAgent(): Agent {
|
|||
return "active";
|
||||
},
|
||||
|
||||
async getActivityState(session: Session): Promise<ActivityState> {
|
||||
// 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<boolean> {
|
||||
try {
|
||||
if (handle.runtimeName === "tmux" && handle.id) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue