diff --git a/artifacts/architecture-design.md b/artifacts/architecture-design.md index d52104f0ef..9a4177eefb 100644 --- a/artifacts/architecture-design.md +++ b/artifacts/architecture-design.md @@ -155,7 +155,6 @@ interface Agent { // Optional postLaunchSetup?(session: Session): Promise; - estimateCost?(session: Session): Promise; } ``` diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 8d7be9816f..7f7dac643a 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -1157,7 +1157,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM session.activitySignal = createActivitySignal("probe_failure", { source: "native" }); } - // Enrich with agent session info (summary, cost, native restore metadata). + // Enrich with lightweight agent session info (summary and native restore metadata). await persistAgentSessionInfo(); } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 87cbd6b0c5..6fa2bb1d77 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -311,7 +311,7 @@ export interface Session { /** Runtime handle for communicating with the session */ runtimeHandle: RuntimeHandle | null; - /** Agent session info (summary, cost, etc.) */ + /** Agent session info (summary and native restore metadata) */ agentInfo: AgentSessionInfo | null; /** When the session was created */ @@ -515,7 +515,7 @@ export interface Agent { */ isProcessRunning(handle: RuntimeHandle): Promise; - /** Extract information from agent's internal data (summary, cost, session ID) */ + /** Extract lightweight information from agent-owned metadata (summary and native session ID) */ getSessionInfo(session: Session): Promise; /** @@ -636,14 +636,6 @@ export interface AgentSessionInfo { agentSessionId: string | null; /** Agent-owned metadata worth persisting for later restore. */ metadata?: Record; - /** Estimated cost so far */ - cost?: CostEstimate; -} - -export interface CostEstimate { - inputTokens: number; - outputTokens: number; - estimatedCostUsd: number; } // ============================================================================= diff --git a/packages/plugins/agent-aider/src/index.test.ts b/packages/plugins/agent-aider/src/index.test.ts index 95fb70093b..bcfac6a510 100644 --- a/packages/plugins/agent-aider/src/index.test.ts +++ b/packages/plugins/agent-aider/src/index.test.ts @@ -6,12 +6,13 @@ import { type AgentLaunchConfig, } from "@aoagents/ao-core"; -// Mock fs/promises for getSessionInfo tests (readFile for .aider.chat.history.md) +// Mock fs/promises for getSessionInfo tests vi.mock("node:fs/promises", async (importOriginal) => { const actual = (await importOriginal()) as Record; return { ...actual, readFile: vi.fn().mockRejectedValue(new Error("ENOENT")), + open: vi.fn().mockRejectedValue(new Error("ENOENT")), }; }); @@ -125,6 +126,24 @@ function mockTmuxWithProcess(processName: string, found = true) { }); } +function makeFakeFileHandle(content: string) { + const buf = Buffer.from(content, "utf-8"); + return { + read: vi + .fn() + .mockImplementation( + (buffer: Buffer, offset: number, length: number, position: number | null) => { + const start = position ?? 0; + if (start >= buf.length) return Promise.resolve({ bytesRead: 0, buffer }); + const bytesToCopy = Math.min(length, buf.length - start); + buf.copy(buffer, offset, start, start + bytesToCopy); + return Promise.resolve({ bytesRead: bytesToCopy, buffer }); + }, + ), + close: vi.fn().mockResolvedValue(undefined), + }; +} + beforeEach(() => { vi.clearAllMocks(); }); @@ -412,28 +431,29 @@ describe("getSessionInfo", () => { }); it("returns null when no chat history file exists", async () => { - const { readFile } = await import("node:fs/promises"); - vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT")); + const { open } = await import("node:fs/promises"); + vi.mocked(open).mockRejectedValueOnce(new Error("ENOENT")); expect(await agent.getSessionInfo(makeSession())).toBeNull(); }); it("extracts summary from chat history file", async () => { - const { readFile } = await import("node:fs/promises"); - vi.mocked(readFile).mockResolvedValueOnce( - "# aider chat started\n\n#### Fix the login bug in auth.ts\n\nSome response here...\n", + const { open } = await import("node:fs/promises"); + vi.mocked(open).mockResolvedValueOnce( + makeFakeFileHandle( + "# aider chat started\n\n#### Fix the login bug in auth.ts\n\nSome response here...\n", + ) as never, ); const info = await agent.getSessionInfo(makeSession()); expect(info).not.toBeNull(); expect(info!.summary).toBe("Fix the login bug in auth.ts"); expect(info!.summaryIsFallback).toBe(true); expect(info!.agentSessionId).toBeNull(); - expect(info!.cost).toBeUndefined(); }); it("truncates long summaries to 120 chars", async () => { - const { readFile } = await import("node:fs/promises"); + const { open } = await import("node:fs/promises"); const longMsg = "A".repeat(200); - vi.mocked(readFile).mockResolvedValueOnce(`#### ${longMsg}\n`); + vi.mocked(open).mockResolvedValueOnce(makeFakeFileHandle(`#### ${longMsg}\n`) as never); const info = await agent.getSessionInfo(makeSession()); expect(info!.summary).toHaveLength(123); // 120 + "..." expect(info!.summary!.endsWith("...")).toBe(true); diff --git a/packages/plugins/agent-aider/src/index.ts b/packages/plugins/agent-aider/src/index.ts index 3fd871fee4..85e9215225 100644 --- a/packages/plugins/agent-aider/src/index.ts +++ b/packages/plugins/agent-aider/src/index.ts @@ -23,7 +23,7 @@ import { } from "@aoagents/ao-core"; import { execFile, execFileSync } from "node:child_process"; import { promisify } from "node:util"; -import { stat, access, readFile } from "node:fs/promises"; +import { stat, access, open } from "node:fs/promises"; import { join } from "node:path"; import { constants, readFileSync } from "node:fs"; @@ -59,7 +59,15 @@ async function getChatHistoryMtime(workspacePath: string): Promise async function extractAiderSummary(workspacePath: string): Promise { try { const chatFile = join(workspacePath, ".aider.chat.history.md"); - const content = await readFile(chatFile, "utf-8"); + const handle = await open(chatFile, "r"); + let content: string; + try { + const buffer = Buffer.allocUnsafe(64 * 1024); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + content = buffer.subarray(0, bytesRead).toString("utf-8"); + } finally { + await handle.close(); + } // Aider chat history uses "#### " prefix for user messages for (const line of content.split("\n")) { @@ -277,7 +285,6 @@ function createAiderAgent(): Agent { summary, summaryIsFallback: true, agentSessionId: null, - // Aider doesn't expose token/cost data }; }, diff --git a/packages/plugins/agent-claude-code/src/index.test.ts b/packages/plugins/agent-claude-code/src/index.test.ts index 8b65084b31..f30716a2ba 100644 --- a/packages/plugins/agent-claude-code/src/index.test.ts +++ b/packages/plugins/agent-claude-code/src/index.test.ts @@ -17,6 +17,7 @@ const { mockReadFile, mockReadFileSync, mockStat, + mockOpen, mockHomedir, mockWriteFile, mockMkdir, @@ -29,6 +30,7 @@ const { mockReadFile: vi.fn(), mockReadFileSync: vi.fn(() => ""), mockStat: vi.fn(), + mockOpen: vi.fn(), mockHomedir: vi.fn(() => "/mock/home"), mockWriteFile: vi.fn().mockResolvedValue(undefined), mockMkdir: vi.fn().mockResolvedValue(undefined), @@ -48,6 +50,7 @@ vi.mock("node:fs/promises", () => ({ readdir: mockReaddir, readFile: mockReadFile, stat: mockStat, + open: mockOpen, writeFile: mockWriteFile, mkdir: mockMkdir, chmod: mockChmod, @@ -148,14 +151,41 @@ function mockTmuxWithProcess(processName = "claude", tty = "/dev/ttys001", pid = }); } +function makeFakeFileHandle(content: string) { + const buf = Buffer.from(content, "utf-8"); + return { + read: vi + .fn() + .mockImplementation( + (buffer: Buffer, offset: number, length: number, position: number | null) => { + const start = position ?? 0; + if (start >= buf.length) return Promise.resolve({ bytesRead: 0, buffer }); + const bytesToCopy = Math.min(length, buf.length - start); + buf.copy(buffer, offset, start, start + bytesToCopy); + return Promise.resolve({ bytesRead: bytesToCopy, buffer }); + }, + ), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function setupMockOpenContent(content: string) { + mockOpen.mockImplementation(async () => makeFakeFileHandle(content)); +} + function mockJsonlFiles( jsonlContent: string, files = ["session-abc123.jsonl"], mtime = new Date(1700000000000), ) { mockReaddir.mockResolvedValue(files); - mockStat.mockResolvedValue({ mtimeMs: mtime.getTime(), mtime }); + mockStat.mockResolvedValue({ + mtimeMs: mtime.getTime(), + mtime, + size: Buffer.byteLength(jsonlContent), + }); mockReadFile.mockResolvedValue(jsonlContent); + setupMockOpenContent(jsonlContent); } // --------------------------------------------------------------------------- @@ -167,6 +197,7 @@ beforeEach(() => { mockHomedir.mockReturnValue("/mock/home"); // Default: non-Windows so existing tests are unaffected mockIsWindows.mockReturnValue(false); + setupMockOpenContent(""); }); describe("toClaudeProjectPath", () => { @@ -387,7 +418,10 @@ describe("isProcessRunning", () => { ["windows exe", "claude.exe"], ["js shim", "claude.js"], ["hyphenated name", "claude-code"], - ["node-shim npm install", "node /opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js"], + [ + "node-shim npm install", + "node /opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js", + ], ])("returns true for %s (%s)", async (_label, args) => { mockExecFileAsync.mockImplementation((cmd: string) => { if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys001\n", stderr: "" }); @@ -529,9 +563,12 @@ describe("detectActivity (retired — see #1941)", () => { " Retrying in 19s · attempt 7/10\n", "✻ Fluttering… (6m 49s · ↓ 26.9k tokens)\n", "some random terminal output\n", - ])("returns idle for ALL non-empty input (no terminal-regex active/waiting_input/blocked): %s", (input) => { - expect(agent.detectActivity(input)).toBe("idle"); - }); + ])( + "returns idle for ALL non-empty input (no terminal-regex active/waiting_input/blocked): %s", + (input) => { + expect(agent.detectActivity(input)).toBe("idle"); + }, + ); }); // ========================================================================= @@ -681,103 +718,18 @@ describe("getSessionInfo", () => { }); }); - describe("cost estimation", () => { - it("aggregates usage.input_tokens and usage.output_tokens", async () => { - const jsonl = [ - '{"type":"user","message":{"content":"hi"}}', - '{"type":"assistant","usage":{"input_tokens":1000,"output_tokens":500}}', - '{"type":"assistant","usage":{"input_tokens":2000,"output_tokens":300}}', - ].join("\n"); - mockJsonlFiles(jsonl); - const result = await agent.getSessionInfo(makeSession()); - expect(result?.cost?.inputTokens).toBe(3000); - expect(result?.cost?.outputTokens).toBe(800); - expect(result?.cost?.estimatedCostUsd).toBeCloseTo(0.009 + 0.012, 6); - }); - - it("includes cache tokens in input count", async () => { - const jsonl = [ - '{"type":"user","message":{"content":"hi"}}', - '{"type":"assistant","usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":500,"cache_creation_input_tokens":200}}', - ].join("\n"); - mockJsonlFiles(jsonl); - const result = await agent.getSessionInfo(makeSession()); - expect(result?.cost?.inputTokens).toBe(800); - expect(result?.cost?.outputTokens).toBe(50); - }); - - it("uses model-aware pricing when cached tokens are present", async () => { - const jsonl = [ - '{"type":"assistant","model":"claude-sonnet-4-5","usage":{"input_tokens":1000,"output_tokens":100,"cache_read_input_tokens":10000,"cache_creation_input_tokens":2000}}', - ].join("\n"); - mockJsonlFiles(jsonl); - const result = await agent.getSessionInfo(makeSession()); - expect(result?.cost?.inputTokens).toBe(13000); - expect(result?.cost?.outputTokens).toBe(100); - expect(result?.cost?.estimatedCostUsd).toBeGreaterThan(0); - }); - - it("uses costUSD field when present", async () => { - const jsonl = [ - '{"type":"user","message":{"content":"hi"}}', - '{"costUSD":0.05}', - '{"costUSD":0.03}', - ].join("\n"); - mockJsonlFiles(jsonl); - const result = await agent.getSessionInfo(makeSession()); - expect(result?.cost?.estimatedCostUsd).toBeCloseTo(0.08); - }); - - it("prefers costUSD over estimatedCostUsd to avoid double-counting", async () => { - const jsonl = [ - '{"type":"user","message":{"content":"hi"}}', - '{"costUSD":0.10,"estimatedCostUsd":0.10}', - ].join("\n"); - mockJsonlFiles(jsonl); - const result = await agent.getSessionInfo(makeSession()); - // Should use costUSD only, not sum both - expect(result?.cost?.estimatedCostUsd).toBeCloseTo(0.1); - }); - - it("falls back to estimatedCostUsd when costUSD is absent", async () => { - const jsonl = [ - '{"type":"user","message":{"content":"hi"}}', - '{"estimatedCostUsd":0.12}', - ].join("\n"); - mockJsonlFiles(jsonl); - const result = await agent.getSessionInfo(makeSession()); - expect(result?.cost?.estimatedCostUsd).toBeCloseTo(0.12); - }); - - it("uses direct inputTokens/outputTokens fields", async () => { - const jsonl = [ - '{"type":"user","message":{"content":"hi"}}', - '{"inputTokens":5000,"outputTokens":1000}', - ].join("\n"); - mockJsonlFiles(jsonl); - const result = await agent.getSessionInfo(makeSession()); - expect(result?.cost?.inputTokens).toBe(5000); - expect(result?.cost?.outputTokens).toBe(1000); - }); - - it("returns undefined cost when no usage data", async () => { - const jsonl = '{"type":"user","message":{"content":"hi"}}'; - mockJsonlFiles(jsonl); - const result = await agent.getSessionInfo(makeSession()); - expect(result?.cost).toBeUndefined(); - }); - }); - describe("file selection", () => { it("picks the most recently modified JSONL file", async () => { mockReaddir.mockResolvedValue(["old.jsonl", "new.jsonl"]); mockStat.mockImplementation((path: string) => { if (path.endsWith("old.jsonl")) { - return Promise.resolve({ mtimeMs: 1000, mtime: new Date(1000) }); + return Promise.resolve({ mtimeMs: 1000, mtime: new Date(1000), size: 48 }); } - return Promise.resolve({ mtimeMs: 2000, mtime: new Date(2000) }); + return Promise.resolve({ mtimeMs: 2000, mtime: new Date(2000), size: 48 }); }); - mockReadFile.mockResolvedValue('{"type":"user","message":{"content":"hi"}}'); + const content = '{"type":"user","message":{"content":"hi"}}'; + mockReadFile.mockResolvedValue(content); + setupMockOpenContent(content); const result = await agent.getSessionInfo(makeSession()); expect(result?.agentSessionId).toBe("new"); }); @@ -788,9 +740,11 @@ describe("getSessionInfo", () => { if (path.endsWith("broken.jsonl")) { return Promise.reject(new Error("ENOENT")); } - return Promise.resolve({ mtimeMs: 1000, mtime: new Date(1000) }); + return Promise.resolve({ mtimeMs: 1000, mtime: new Date(1000), size: 48 }); }); - mockReadFile.mockResolvedValue('{"type":"user","message":{"content":"hi"}}'); + const content = '{"type":"user","message":{"content":"hi"}}'; + mockReadFile.mockResolvedValue(content); + setupMockOpenContent(content); const result = await agent.getSessionInfo(makeSession()); expect(result?.agentSessionId).toBe("good"); }); @@ -1065,25 +1019,22 @@ describe("setupWorkspaceHooks — activity-updater (#1941)", () => { expect(chmodCall![1]).toBe(0o755); }); - it.each(ACTIVITY_EVENTS)( - "registers the activity-updater hook on %s", - async (event) => { - mockWriteFile.mockClear(); - await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig); + it.each(ACTIVITY_EVENTS)("registers the activity-updater hook on %s", async (event) => { + mockWriteFile.mockClear(); + await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig); - const settings = getParsedSettings(); - const hookGroup = (settings.hooks as Record)[event] as Array<{ - matcher: string; - hooks: Array<{ command: string; timeout?: number }>; - }>; - expect(hookGroup).toBeDefined(); - const activity = hookGroup.flatMap((g) => g.hooks).find((h) => h.command === ACTIVITY_CMD_UNIX); - expect(activity).toBeDefined(); - // The script does a single JSON parse + append — short timeout keeps a - // stuck hook from slowing the turn down. - expect(activity!.timeout).toBe(2000); - }, - ); + const settings = getParsedSettings(); + const hookGroup = (settings.hooks as Record)[event] as Array<{ + matcher: string; + hooks: Array<{ command: string; timeout?: number }>; + }>; + expect(hookGroup).toBeDefined(); + const activity = hookGroup.flatMap((g) => g.hooks).find((h) => h.command === ACTIVITY_CMD_UNIX); + expect(activity).toBeDefined(); + // The script does a single JSON parse + append — short timeout keeps a + // stuck hook from slowing the turn down. + expect(activity!.timeout).toBe(2000); + }); it("registers activity-updater PostToolUse alongside metadata-updater", async () => { mockWriteFile.mockClear(); @@ -1126,9 +1077,9 @@ describe("setupWorkspaceHooks — activity-updater (#1941)", () => { const hookGroup = (settings.hooks as Record)[event] as Array<{ hooks: Array<{ command: string }>; }>; - const activityHooks = hookGroup.flatMap((g) => g.hooks).filter( - (h) => h.command === ACTIVITY_CMD_UNIX, - ); + const activityHooks = hookGroup + .flatMap((g) => g.hooks) + .filter((h) => h.command === ACTIVITY_CMD_UNIX); expect(activityHooks).toHaveLength(1); } }); @@ -1214,9 +1165,7 @@ describe("setupWorkspaceHooks — activity-updater (#1941)", () => { matcher: string; hooks: Array<{ command: string }>; }>; - const sharedEntry = pre.find((g) => - g.hooks.some((h) => h.command === "echo user-edits-only"), - ); + const sharedEntry = pre.find((g) => g.hooks.some((h) => h.command === "echo user-edits-only")); expect(sharedEntry).toBeDefined(); // Matcher must NOT be overwritten — user's hook keeps firing on "Edit|Write" expect(sharedEntry!.matcher).toBe("Edit|Write"); @@ -1248,7 +1197,9 @@ describe("setupWorkspaceHooks — activity-updater (#1941)", () => { const stopGroup = (settings.hooks as Record)["Stop"] as Array<{ hooks: Array<{ command: string }>; }>; - expect(stopGroup.flatMap((g) => g.hooks).some((h) => h.command === ACTIVITY_CMD_WIN)).toBe(true); + expect(stopGroup.flatMap((g) => g.hooks).some((h) => h.command === ACTIVITY_CMD_WIN)).toBe( + true, + ); mockIsWindows.mockReturnValue(false); }); diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 934fe3be29..94b9e934dd 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -7,7 +7,6 @@ import { type AgentLaunchConfig, type ActivityDetection, type ActivityState, - type CostEstimate, type PluginModule, type ProjectConfig, type ProcessProbeResult, @@ -635,32 +634,12 @@ interface JsonlLine { type?: string; summary?: string; message?: { content?: string; role?: string }; - // Cost/usage fields - costUSD?: number; - usage?: { - input_tokens?: number; - output_tokens?: number; - cache_read_input_tokens?: number; - cache_creation_input_tokens?: number; - }; - inputTokens?: number; - outputTokens?: number; - estimatedCostUsd?: number; } -/** - * 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 @aoagents/ao-core. - */ - /** * Parse only the last `maxBytes` of a JSONL file. - * Summaries and recent activity are always near the end, so reading the whole - * file (which can be 100MB+) is wasteful. For files smaller than maxBytes, - * readFile is used directly. For large files, only the tail is read via a - * file handle to avoid loading the entire file into memory. + * Summaries and recent activity are usually near the end, so only read a bounded + * tail chunk. This keeps dashboard enrichment away from full transcript loads. */ async function parseJsonlFileTail(filePath: string, maxBytes = 131_072): Promise { let content: string; @@ -668,20 +647,14 @@ async function parseJsonlFileTail(filePath: string, maxBytes = 131_072): Promise try { const { size = 0 } = await stat(filePath); offset = Math.max(0, size - maxBytes); - if (offset === 0) { - // Small file (or unknown size) — read it whole - content = await readFile(filePath, "utf-8"); - } else { - // Large file — read only the tail via a file handle - const handle = await open(filePath, "r"); - try { - const length = size - offset; - const buffer = Buffer.allocUnsafe(length); - await handle.read(buffer, 0, length, offset); - content = buffer.toString("utf-8"); - } finally { - await handle.close(); - } + const handle = await open(filePath, "r"); + try { + const length = Math.min(maxBytes, size); + const buffer = Buffer.allocUnsafe(length); + const { bytesRead } = await handle.read(buffer, 0, length, offset); + content = buffer.subarray(0, bytesRead).toString("utf-8"); + } finally { + await handle.close(); } } catch { return []; @@ -733,65 +706,6 @@ function extractSummary(lines: JsonlLine[]): { summary: string; isFallback: bool return null; } -/** Aggregate cost estimate from JSONL usage events */ -function extractCost(lines: JsonlLine[]): CostEstimate | undefined { - let inputTokens = 0; - let outputTokens = 0; - let cachedReadTokens = 0; - let cacheCreationTokens = 0; - let totalCost = 0; - - for (const line of lines) { - // Handle direct cost fields — prefer costUSD; only use estimatedCostUsd - // as fallback to avoid double-counting when both are present. - if (typeof line.costUSD === "number") { - totalCost += line.costUSD; - } else if (typeof line.estimatedCostUsd === "number") { - totalCost += line.estimatedCostUsd; - } - // Handle token counts — prefer the structured `usage` object when present; - // only fall back to flat `inputTokens`/`outputTokens` fields to avoid - // double-counting if a line contains both. - if (line.usage) { - inputTokens += line.usage.input_tokens ?? 0; - cachedReadTokens += line.usage.cache_read_input_tokens ?? 0; - cacheCreationTokens += line.usage.cache_creation_input_tokens ?? 0; - outputTokens += line.usage.output_tokens ?? 0; - } else { - if (typeof line.inputTokens === "number") { - inputTokens += line.inputTokens; - } - if (typeof line.outputTokens === "number") { - outputTokens += line.outputTokens; - } - } - } - - if ( - inputTokens === 0 && - outputTokens === 0 && - totalCost === 0 && - cachedReadTokens === 0 && - cacheCreationTokens === 0 - ) { - return undefined; - } - - if (totalCost === 0) { - totalCost = - (inputTokens / 1_000_000) * 3.0 + - (outputTokens / 1_000_000) * 15.0 + - (cachedReadTokens / 1_000_000) * 0.3 + - (cacheCreationTokens / 1_000_000) * 3.75; - } - - return { - inputTokens: inputTokens + cachedReadTokens + cacheCreationTokens, - outputTokens, - estimatedCostUsd: totalCost, - }; -} - // ============================================================================= // Hook Setup Helper // ============================================================================= @@ -1109,7 +1023,9 @@ function createClaudeCodeAgent(): Agent { if (!session.workspacePath) return null; // Build the Claude project directory path - const projectPath = toClaudeProjectPath(await resolveWorkspaceForClaude(session.workspacePath)); + const projectPath = toClaudeProjectPath( + await resolveWorkspaceForClaude(session.workspacePath), + ); const projectDir = join(homedir(), ".claude", "projects", projectPath); // Find the latest session JSONL file @@ -1129,7 +1045,6 @@ function createClaudeCodeAgent(): Agent { summaryIsFallback: summaryResult?.isFallback, agentSessionId, metadata: { claudeSessionUuid: agentSessionId }, - cost: extractCost(lines), }; }, @@ -1139,7 +1054,9 @@ function createClaudeCodeAgent(): Agent { if (!session.workspacePath) return null; // Find Claude's project directory for this workspace - const projectPath = toClaudeProjectPath(await resolveWorkspaceForClaude(session.workspacePath)); + const projectPath = toClaudeProjectPath( + await resolveWorkspaceForClaude(session.workspacePath), + ); const projectDir = join(homedir(), ".claude", "projects", projectPath); // Find the latest session JSONL file diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index 5499873a1d..8f2a8fb9c2 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -1,5 +1,4 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import type * as Readline from "node:readline"; import { createActivitySignal, type Session, @@ -21,8 +20,6 @@ const { mockStat, mockLstat, mockOpen, - mockCreateReadStream, - mockCreateInterface, mockHomedir, mockReadLastJsonlEntry, mockIsWindows, @@ -36,8 +33,6 @@ const { mockStat: vi.fn(), mockLstat: vi.fn(), mockOpen: vi.fn(), - mockCreateReadStream: vi.fn(), - mockCreateInterface: vi.fn(), mockHomedir: vi.fn(() => "/mock/home"), mockReadLastJsonlEntry: vi.fn(), mockIsWindows: vi.fn(() => false), @@ -67,20 +62,8 @@ vi.mock("node:crypto", () => ({ vi.mock("node:fs", () => ({ existsSync: vi.fn(() => false), - createReadStream: mockCreateReadStream, })); -vi.mock("node:readline", async (importOriginal) => { - const actual = await importOriginal(); - mockCreateInterface.mockImplementation((...args: Parameters) => - actual.createInterface(...args), - ); - return { - ...actual, - createInterface: mockCreateInterface, - }; -}); - vi.mock("node:os", () => ({ homedir: mockHomedir, })); @@ -94,7 +77,6 @@ vi.mock("@aoagents/ao-core", async (importOriginal) => { }; }); -import { Readable } from "node:stream"; import { join as pathJoin } from "node:path"; import { create, @@ -118,6 +100,32 @@ function makeSession(overrides: Partial = {}): Session { timestamp: new Date(), source: "native", }), + lifecycle: { + version: 2, + session: { + kind: "worker", + state: "working", + reason: "task_in_progress", + startedAt: new Date().toISOString(), + completedAt: null, + terminatedAt: null, + lastTransitionAt: new Date().toISOString(), + }, + pr: { + state: "none", + reason: "not_created", + number: null, + url: null, + lastObservedAt: null, + }, + runtime: { + state: "alive", + reason: "process_running", + lastObservedAt: new Date().toISOString(), + handle: null, + tmuxName: null, + }, + }, branch: "feat/test", issueId: null, pr: null, @@ -153,6 +161,10 @@ function makeLaunchConfig(overrides: Partial = {}): AgentLaun }; } +function jsonl(...lines: Record[]): string { + return lines.map((l) => JSON.stringify(l)).join("\n") + "\n"; +} + function mockTmuxWithProcess(processName: string, found = true) { mockExecFileAsync.mockImplementation((cmd: string, args: string[]) => { if (cmd === "tmux" && args[0] === "list-panes") { @@ -200,23 +212,7 @@ function makeFakeFileHandle(content: string) { * reading `content`. This is used by sessionFileMatchesCwd. */ function setupMockOpen(content: string) { - mockOpen.mockResolvedValue(makeFakeFileHandle(content)); -} - -/** - * Create a Readable stream from a string. Used to mock createReadStream - * for the streaming JSONL parser (streamCodexSessionData). - */ -function makeContentStream(content: string): Readable { - return Readable.from(Buffer.from(content, "utf-8")); -} - -/** - * Set up mockCreateReadStream to return a readable stream with the given content. - * Used by getSessionInfo/getRestoreCommand which stream files line-by-line. - */ -function setupMockStream(content: string) { - mockCreateReadStream.mockReturnValue(makeContentStream(content)); + mockOpen.mockImplementation(async () => makeFakeFileHandle(content)); } beforeEach(() => { @@ -225,12 +221,9 @@ beforeEach(() => { mockHomedir.mockReturnValue("/mock/home"); // Default: open() returns a handle with empty content (no session_meta match). // Session tests call setupMockOpen(content) to override. - mockOpen.mockResolvedValue(makeFakeFileHandle("")); + mockOpen.mockImplementation(async () => makeFakeFileHandle("")); // Default: lstat rejects (no subdirectories). Session tests override as needed. mockLstat.mockRejectedValue(new Error("ENOENT")); - // Default: createReadStream returns an empty stream. Session tests call - // setupMockStream(content) to override. - mockCreateReadStream.mockReturnValue(makeContentStream("")); }); // ========================================================================= @@ -691,7 +684,7 @@ describe("getActivityState", () => { expect(await agent.getActivityState(session)).toBeNull(); }); - it("uses persisted codexThreadId filename without cwd-prefix open scans", async () => { + it("uses persisted codexThreadId filename lookup before bounded metadata reads", async () => { mockTmuxWithProcess("codex"); mockReaddir.mockResolvedValue(["rollout-2026-05-22T00-00-00-thread-fast-activity.jsonl"]); const modifiedAt = new Date(); @@ -1022,11 +1015,6 @@ describe("getActivityState", () => { describe("getSessionInfo", () => { const agent = create(); - // Helper to build JSONL content from lines - function jsonl(...lines: Record[]): string { - return lines.map((l) => JSON.stringify(l)).join("\n") + "\n"; - } - it("returns null when workspacePath is null", async () => { expect(await agent.getSessionInfo(makeSession({ workspacePath: null }))).toBeNull(); }); @@ -1045,7 +1033,7 @@ describe("getSessionInfo", () => { expect(await agent.getSessionInfo(makeSession())).toBeNull(); }); - it("uses persisted codexThreadId filename without cwd-prefix open scans", async () => { + it("uses persisted codexThreadId filename lookup before bounded metadata reads", async () => { const sessionContent = jsonl( { type: "session_meta", @@ -1062,7 +1050,7 @@ describe("getSessionInfo", () => { ); mockReaddir.mockResolvedValue(["rollout-2026-05-22T00-00-00-thread-fast-info.jsonl"]); - setupMockStream(sessionContent); + setupMockOpen(sessionContent); const result = await agent.getSessionInfo( makeSession({ @@ -1072,8 +1060,173 @@ describe("getSessionInfo", () => { ); expect(result).not.toBeNull(); - expect(result!.agentSessionId).toBe("rollout-2026-05-22T00-00-00-thread-fast-info"); + expect(result!.agentSessionId).toBe("thread-fast-info"); expect(result!.summary).toBe("Codex session (gpt-5.5)"); + expect(mockOpen).toHaveBeenCalledTimes(1); + }); + + it("returns metadata-only info for terminal sessions without JSONL reads", async () => { + const result = await agent.getSessionInfo( + makeSession({ + status: "terminated", + activity: "exited", + metadata: { codexThreadId: "thread-terminal", codexModel: "gpt-5.4" }, + lifecycle: { + version: 2, + session: { + kind: "worker", + state: "terminated", + reason: "runtime_lost", + startedAt: new Date().toISOString(), + completedAt: null, + terminatedAt: new Date().toISOString(), + lastTransitionAt: new Date().toISOString(), + }, + pr: { + state: "none", + reason: "not_created", + number: null, + url: null, + lastObservedAt: null, + }, + runtime: { + state: "missing", + reason: "tmux_missing", + lastObservedAt: new Date().toISOString(), + handle: null, + tmuxName: "test-1", + }, + }, + }), + ); + + expect(result).toEqual({ + summary: "Codex session (gpt-5.4)", + summaryIsFallback: true, + agentSessionId: "thread-terminal", + metadata: { codexThreadId: "thread-terminal", codexModel: "gpt-5.4" }, + }); + expect(mockReaddir).not.toHaveBeenCalled(); + expect(mockOpen).not.toHaveBeenCalled(); + }); + + it("does not read JSONL for terminal sessions that only have codexThreadId", async () => { + const result = await agent.getSessionInfo( + makeSession({ + status: "killed", + activity: "exited", + metadata: { codexThreadId: "thread-without-model" }, + lifecycle: { + ...makeSession().lifecycle, + session: { + ...makeSession().lifecycle.session, + state: "terminated", + reason: "runtime_lost", + terminatedAt: new Date().toISOString(), + }, + }, + }), + ); + + expect(result).toMatchObject({ + summary: null, + summaryIsFallback: true, + agentSessionId: "thread-without-model", + metadata: { codexThreadId: "thread-without-model" }, + }); + expect(mockReaddir).not.toHaveBeenCalled(); + expect(mockOpen).not.toHaveBeenCalled(); + }); + + it("uses core terminal lifecycle signals for the metadata-only fast path", async () => { + const mergedResult = await agent.getSessionInfo( + makeSession({ + status: "working", + activity: "active", + metadata: { codexThreadId: "thread-pr-merged" }, + lifecycle: { + ...makeSession().lifecycle, + pr: { + state: "merged", + reason: "merged", + number: 123, + url: "https://github.com/example/repo/pull/123", + lastObservedAt: new Date().toISOString(), + }, + }, + }), + ); + + const runtimeExitedResult = await agent.getSessionInfo( + makeSession({ + status: "working", + activity: "active", + metadata: { codexThreadId: "thread-runtime-exited" }, + lifecycle: { + ...makeSession().lifecycle, + runtime: { + state: "exited", + reason: "process_missing", + lastObservedAt: new Date().toISOString(), + handle: null, + tmuxName: "test-1", + }, + }, + }), + ); + + expect(mergedResult?.agentSessionId).toBe("thread-pr-merged"); + expect(runtimeExitedResult?.agentSessionId).toBe("thread-runtime-exited"); + expect(mockReaddir).not.toHaveBeenCalled(); + expect(mockOpen).not.toHaveBeenCalled(); + }); + + it("uses bounded metadata reads for live codexThreadId sessions missing model metadata", async () => { + const sessionContent = jsonl( + { + type: "session_meta", + payload: { id: "thread-live-no-model" }, + }, + { + type: "turn_context", + payload: { model: "gpt-5.5" }, + }, + ); + + mockReaddir.mockResolvedValue(["rollout-2026-05-22T00-00-00-thread-live-no-model.jsonl"]); + setupMockOpen(sessionContent); + + const result = await agent.getSessionInfo( + makeSession({ + status: "working", + activity: "active", + metadata: { codexThreadId: "thread-live-no-model" }, + }), + ); + + expect(result).toMatchObject({ + summary: "Codex session (gpt-5.5)", + agentSessionId: "thread-live-no-model", + metadata: { codexThreadId: "thread-live-no-model", codexModel: "gpt-5.5" }, + }); + expect(mockOpen).toHaveBeenCalled(); + }); + + it("returns metadata-only info for live sessions that already have codexModel metadata", async () => { + const result = await agent.getSessionInfo( + makeSession({ + status: "working", + activity: "active", + metadata: { codexThreadId: "thread-live-with-model", codexModel: "gpt-5.4" }, + }), + ); + + expect(result).toMatchObject({ + summary: "Codex session (gpt-5.4)", + agentSessionId: "thread-live-with-model", + metadata: { codexThreadId: "thread-live-with-model", codexModel: "gpt-5.4" }, + }); + expect(mockReaddir).not.toHaveBeenCalled(); expect(mockOpen).not.toHaveBeenCalled(); }); @@ -1086,7 +1239,7 @@ describe("getSessionInfo", () => { }); mockReaddir.mockResolvedValue(["rollout-thread-cached-info.jsonl"]); - mockCreateReadStream.mockImplementation(() => makeContentStream(sessionContent)); + setupMockOpen(sessionContent); const first = await agent.getSessionInfo( makeSession({ @@ -1104,7 +1257,7 @@ describe("getSessionInfo", () => { expect(first).not.toBeNull(); expect(second).not.toBeNull(); expect(mockReaddir).toHaveBeenCalledTimes(1); - expect(mockOpen).not.toHaveBeenCalled(); + expect(mockOpen).toHaveBeenCalledTimes(2); }); it("chooses the newest duplicate codexThreadId filename match by mtime", async () => { @@ -1126,10 +1279,10 @@ describe("getSessionInfo", () => { if (path.includes("rollout-new-thread-dupe")) return Promise.resolve({ mtimeMs: 2000 }); return Promise.reject(new Error("ENOENT")); }); - mockCreateReadStream.mockImplementation((path: string) => { - if (path.includes("rollout-old-thread-dupe")) return makeContentStream(oldContent); - if (path.includes("rollout-new-thread-dupe")) return makeContentStream(newContent); - return makeContentStream(""); + mockOpen.mockImplementation(async (path: string) => { + if (path.includes("rollout-old-thread-dupe")) return makeFakeFileHandle(oldContent); + if (path.includes("rollout-new-thread-dupe")) return makeFakeFileHandle(newContent); + throw new Error("ENOENT"); }); const result = await agent.getSessionInfo( @@ -1140,9 +1293,9 @@ describe("getSessionInfo", () => { ); expect(result).not.toBeNull(); - expect(result!.agentSessionId).toBe("rollout-new-thread-dupe"); + expect(result!.agentSessionId).toBe("thread-dupe"); expect(result!.summary).toBe("Codex session (new-model)"); - expect(mockOpen).not.toHaveBeenCalled(); + expect(mockOpen).toHaveBeenCalledTimes(1); }); it("does not treat infix filename matches as thread-id hits", async () => { @@ -1157,14 +1310,12 @@ describe("getSessionInfo", () => { expect(result).toBeNull(); expect(mockOpen).not.toHaveBeenCalled(); - expect(mockCreateReadStream).not.toHaveBeenCalled(); }); it("returns null when no session files match the workspace cwd", async () => { mockReaddir.mockResolvedValue(["session-abc.jsonl"]); const content = jsonl({ type: "session_meta", cwd: "/other/workspace", model: "gpt-4o" }); setupMockOpen(content); - mockReadFile.mockResolvedValue(content); expect( await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })), ).toBeNull(); @@ -1179,7 +1330,6 @@ describe("getSessionInfo", () => { mockReaddir.mockResolvedValue(["rollout-cwd-fallback.jsonl"]); setupMockOpen(sessionContent); - setupMockStream(sessionContent); mockStat.mockResolvedValue({ mtimeMs: 1000 }); const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })); @@ -1189,7 +1339,7 @@ describe("getSessionInfo", () => { expect(mockOpen).toHaveBeenCalled(); }); - it("returns session info with cost and model when matching session found", async () => { + it("returns session info with model while ignoring token_count events", async () => { const sessionContent = jsonl( { type: "session_meta", cwd: "/workspace/test", model: "o3-mini" }, { @@ -1216,7 +1366,6 @@ describe("getSessionInfo", () => { mockReaddir.mockResolvedValue(["session-123.jsonl"]); setupMockOpen(sessionContent); - setupMockStream(sessionContent); mockReadFile.mockResolvedValue(sessionContent); mockStat.mockResolvedValue({ mtimeMs: 1000 }); @@ -1226,13 +1375,6 @@ describe("getSessionInfo", () => { expect(result!.agentSessionId).toBe("session-123"); expect(result!.summary).toBe("Codex session (o3-mini)"); expect(result!.summaryIsFallback).toBe(true); - expect(result!.cost).toBeDefined(); - // cached tokens count toward effective input spend - // input: 1000 + 2000 + 200 = 3200 - // output: 500 + 300 = 800 - expect(result!.cost!.inputTokens).toBe(3200); - expect(result!.cost!.outputTokens).toBe(800); - expect(result!.cost!.estimatedCostUsd).toBeGreaterThan(0); }); it("parses payload-wrapped Codex session files", async () => { @@ -1270,17 +1412,13 @@ describe("getSessionInfo", () => { mockReaddir.mockResolvedValue(["rollout-abc.jsonl"]); setupMockOpen(sessionContent); - setupMockStream(sessionContent); mockStat.mockResolvedValue({ mtimeMs: 1000 }); const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })); expect(result).not.toBeNull(); - expect(result!.agentSessionId).toBe("rollout-abc"); + expect(result!.agentSessionId).toBe("thread-payload-123"); expect(result!.summary).toBe("Codex session (gpt-5.3-codex)"); - expect(result!.cost).toBeDefined(); - expect(result!.cost!.inputTokens).toBe(3000); - expect(result!.cost!.outputTokens).toBe(800); }); it("does not treat model_provider as the session model", async () => { @@ -1295,7 +1433,6 @@ describe("getSessionInfo", () => { mockReaddir.mockResolvedValue(["rollout-abc.jsonl"]); setupMockOpen(sessionContent); - setupMockStream(sessionContent); mockStat.mockResolvedValue({ mtimeMs: 1000 }); const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })); @@ -1319,11 +1456,6 @@ describe("getSessionInfo", () => { if (path.includes("new-session")) return Promise.resolve(newContent); return Promise.reject(new Error("ENOENT")); }); - mockCreateReadStream.mockImplementation((path: string) => { - if (path.includes("old-session")) return makeContentStream(oldContent); - if (path.includes("new-session")) return makeContentStream(newContent); - return makeContentStream(""); - }); mockStat.mockImplementation((path: string) => { if (path.includes("old-session")) return Promise.resolve({ mtimeMs: 1000 }); if (path.includes("new-session")) return Promise.resolve({ mtimeMs: 2000 }); @@ -1345,15 +1477,13 @@ describe("getSessionInfo", () => { mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })); expect(result).not.toBeNull(); - expect(result!.cost!.inputTokens).toBe(500); - expect(result!.cost!.outputTokens).toBe(200); + expect(result!.summary).toBe("Codex session (gpt-4o)"); }); it("returns null summary when no model in session_meta", async () => { @@ -1364,20 +1494,14 @@ describe("getSessionInfo", () => { mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })); - expect(result).not.toBeNull(); - expect(result!.summary).toBeNull(); - // Verify cost was actually parsed from the stream (not just defaulting to undefined) - expect(result!.cost).toBeDefined(); - expect(result!.cost!.inputTokens).toBe(100); - expect(result!.cost!.outputTokens).toBe(50); + expect(result).toBeNull(); }); - it("returns undefined cost when no token_count events", async () => { + it("returns session info when token_count events are absent", async () => { const content = jsonl( { type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }, { type: "event_msg", msg: { type: "other_event" } }, @@ -1385,55 +1509,25 @@ describe("getSessionInfo", () => { mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })); expect(result).not.toBeNull(); - expect(result!.cost).toBeUndefined(); - // Verify model was actually parsed from the stream (not just defaulting to null) expect(result!.summary).toContain("gpt-4o"); }); it("handles unreadable session files gracefully", async () => { mockReaddir.mockResolvedValue(["sess.jsonl"]); - // open() finds matching session_meta, but readFile fails for full parse - setupMockOpen(jsonl({ type: "session_meta", cwd: "/workspace/test" })); - mockStat.mockResolvedValue({ mtimeMs: 1000 }); - mockReadFile.mockRejectedValue(new Error("EACCES")); - mockCreateReadStream.mockImplementation(() => { - throw new Error("EACCES"); - }); - - expect( - await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })), - ).toBeNull(); - }); - - it("closes readline and destroys the stream when JSONL streaming is interrupted", async () => { - const content = jsonl({ type: "session_meta", cwd: "/workspace/test" }); - mockReaddir.mockResolvedValue(["sess.jsonl"]); - setupMockOpen(content); + const readableMatch = makeFakeFileHandle( + jsonl({ type: "session_meta", cwd: "/workspace/test" }), + ); + mockOpen.mockResolvedValueOnce(readableMatch).mockRejectedValueOnce(new Error("EACCES")); mockStat.mockResolvedValue({ mtimeMs: 1000 }); - const stream = makeContentStream(content); - const destroySpy = vi.spyOn(stream, "destroy"); - const closeSpy = vi.fn(); - mockCreateReadStream.mockReturnValue(stream); - mockCreateInterface.mockImplementationOnce(() => ({ - close: closeSpy, - async *[Symbol.asyncIterator]() { - yield JSON.stringify({ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }); - throw new Error("aborted"); - }, - })); - expect( await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })), ).toBeNull(); - expect(closeSpy).toHaveBeenCalledTimes(1); - expect(destroySpy).toHaveBeenCalledTimes(1); }); it("skips session files when stat throws", async () => { @@ -1452,17 +1546,10 @@ describe("getSessionInfo", () => { mockReaddir.mockResolvedValue(["sess.jsonl"]); // open() finds matching session_meta for cwd check setupMockOpen(jsonl({ type: "session_meta", cwd: "/workspace/test" })); - // readFile (full parse) returns only garbage - mockReadFile.mockResolvedValue("not json\n\n \n"); - setupMockStream("not json\n\n \n"); mockStat.mockResolvedValue({ mtimeMs: 1000 }); const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })); - // With streaming parser, garbage lines are skipped gracefully and a result - // is returned with null summary and undefined cost (no valid data extracted). - expect(result).not.toBeNull(); - expect(result!.summary).toBeNull(); - expect(result!.cost).toBeUndefined(); + expect(result).toBeNull(); }); it("finds session files in date-sharded subdirectories (YYYY/MM/DD)", async () => { @@ -1480,7 +1567,6 @@ describe("getSessionInfo", () => { mockStat.mockResolvedValue({ mtimeMs: 2000 }); const content = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "o3-mini" }); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })); @@ -1493,7 +1579,6 @@ describe("getSessionInfo", () => { mockReaddir.mockResolvedValue(["notes.txt", "config.json", "sess.jsonl"]); const content = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); // Non-JSONL entries trigger lstat to check isDirectory() mockLstat.mockResolvedValue({ isDirectory: () => false }); @@ -1502,12 +1587,11 @@ describe("getSessionInfo", () => { const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })); expect(result).not.toBeNull(); - // With streaming parser, readFile is no longer used for full parse; - // cwd check uses open(), data extraction uses createReadStream. + // getSessionInfo uses bounded open() reads, not readFile() full-file parses. const readFileCalls = mockReadFile.mock.calls.filter( (call: string[]) => typeof call[0] === "string" && call[0].includes("sessions/"), ); - expect(readFileCalls.length).toBe(0); // streaming replaces readFile for full parse + expect(readFileCalls.length).toBe(0); }); }); @@ -1517,10 +1601,6 @@ describe("getSessionInfo", () => { describe("getRestoreCommand", () => { const agent = create(); - function jsonl(...lines: Record[]): string { - return lines.map((l) => JSON.stringify(l)).join("\n") + "\n"; - } - function makeProjectConfig(overrides: Record = {}) { return { name: "test-project", @@ -1555,7 +1635,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); const session = makeSession({ workspacePath: "/workspace/test" }); @@ -1570,7 +1649,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); @@ -1617,7 +1695,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); const session = makeSession({ workspacePath: "/workspace/test" }); @@ -1639,7 +1716,6 @@ describe("getRestoreCommand", () => { }); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); const session = makeSession({ workspacePath: "/workspace/test" }); @@ -1656,7 +1732,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); @@ -1682,7 +1757,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); @@ -1707,7 +1781,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); @@ -1730,7 +1803,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); @@ -1753,7 +1825,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); @@ -1775,7 +1846,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); @@ -1803,7 +1873,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); @@ -1826,7 +1895,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); @@ -1839,13 +1907,10 @@ describe("getRestoreCommand", () => { it("handles unreadable session files gracefully", async () => { mockReaddir.mockResolvedValue(["sess.jsonl"]); - // open() finds matching session_meta for cwd check - setupMockOpen(jsonl({ type: "session_meta", cwd: "/workspace/test" })); - // readFile (full parse) fails - mockReadFile.mockRejectedValue(new Error("EACCES")); - mockCreateReadStream.mockImplementation(() => { - throw new Error("EACCES"); - }); + const readableMatch = makeFakeFileHandle( + jsonl({ type: "session_meta", cwd: "/workspace/test" }), + ); + mockOpen.mockResolvedValueOnce(readableMatch).mockRejectedValueOnce(new Error("EACCES")); mockStat.mockResolvedValue({ mtimeMs: 1000 }); const session = makeSession({ workspacePath: "/workspace/test" }); diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index 770c6a5050..730982623b 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -10,12 +10,12 @@ import { recordTerminalActivity, isWindows, PROCESS_PROBE_INDETERMINATE, + isTerminalSession, type Agent, type AgentSessionInfo, type AgentLaunchConfig, type ActivityState, type ActivityDetection, - type CostEstimate, type PluginModule, type ProcessProbeResult, type ProjectConfig, @@ -24,12 +24,10 @@ import { type WorkspaceHooksConfig, } from "@aoagents/ao-core"; import { execFile, execFileSync } from "node:child_process"; -import { createReadStream } from "node:fs"; import { readdir, stat, lstat, open } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, join } from "node:path"; import { StringDecoder } from "node:string_decoder"; -import { createInterface } from "node:readline"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); @@ -58,17 +56,10 @@ export const manifest = { const CODEX_SESSIONS_DIR = join(homedir(), ".codex", "sessions"); const SESSION_MATCH_SCAN_CHUNK_BYTES = 8192; const SESSION_MATCH_SCAN_LINE_LIMIT = 10; +const SESSION_METADATA_SCAN_LINE_LIMIT = 100; +const SESSION_METADATA_SCAN_BYTE_LIMIT = 1_000_000; -interface CodexTokenUsage { - input_tokens?: number; - output_tokens?: number; - cached_input_tokens?: number; - cached_tokens?: number; - reasoning_output_tokens?: number; - reasoning_tokens?: number; -} - -interface CodexJsonlPayload extends CodexTokenUsage { +interface CodexJsonlPayload { id?: string; cwd?: string; model_provider?: string; @@ -78,10 +69,6 @@ interface CodexJsonlPayload extends CodexTokenUsage { content?: string; role?: string; type?: string; - info?: { - total_token_usage?: CodexTokenUsage; - last_token_usage?: CodexTokenUsage; - }; } /** @@ -92,7 +79,6 @@ interface CodexJsonlPayload extends CodexTokenUsage { interface CodexJsonlLine extends CodexJsonlPayload { type?: string; payload?: CodexJsonlPayload; - msg?: CodexTokenUsage & { type?: string }; } function getCodexPayload(entry: CodexJsonlLine): CodexJsonlPayload { @@ -142,10 +128,15 @@ async function collectJsonlFiles(dir: string, depth = 0): Promise { return results; } -async function readJsonlPrefixLines(filePath: string, maxLines: number): Promise { +async function readJsonlPrefixLines( + filePath: string, + maxLines: number, + maxBytes = SESSION_METADATA_SCAN_BYTE_LIMIT, +): Promise { const handle = await open(filePath, "r"); const lines: string[] = []; let partialLine = ""; + let totalBytesRead = 0; // Reuse a single decoder across reads so multi-byte UTF-8 sequences that // straddle a chunk boundary (e.g. CJK characters in base_instructions) get // buffered correctly instead of producing U+FFFD replacement characters. @@ -155,6 +146,7 @@ async function readJsonlPrefixLines(filePath: string, maxLines: number): Promise while (lines.length < maxLines) { const buffer = Buffer.allocUnsafe(SESSION_MATCH_SCAN_CHUNK_BYTES); const { bytesRead } = await handle.read(buffer, 0, buffer.length, null); + totalBytesRead += bytesRead; if (bytesRead === 0) { partialLine += decoder.end(); @@ -164,6 +156,9 @@ async function readJsonlPrefixLines(filePath: string, maxLines: number): Promise } partialLine += decoder.write(buffer.subarray(0, bytesRead)); + if (totalBytesRead > maxBytes) { + break; + } let newlineIndex = partialLine.indexOf("\n"); while (newlineIndex !== -1 && lines.length < maxLines) { @@ -288,48 +283,23 @@ async function findCodexSessionFileByThreadId( return bestMatch?.path ?? fallback; } -/** Aggregated data extracted from a Codex session file via streaming */ -interface CodexSessionData { +interface CodexSessionMetadata { model: string | null; threadId: string | null; - inputTokens: number; - outputTokens: number; - cachedTokens: number; - reasoningTokens: number; } -/** - * Stream a Codex JSONL session file line-by-line and aggregate the data - * we need (model, threadId, token counts) without loading the entire file - * into memory. This is critical because Codex rollout files can be 100 MB+. - */ -async function streamCodexSessionData(filePath: string): Promise { - let stream: ReturnType | null = null; - let rl: ReturnType | null = null; - +async function readCodexSessionMetadata(filePath: string): Promise { + const data: CodexSessionMetadata = { + model: null, + threadId: null, + }; try { - const data: CodexSessionData = { - model: null, - threadId: null, - inputTokens: 0, - outputTokens: 0, - cachedTokens: 0, - reasoningTokens: 0, - }; - stream = createReadStream(filePath, { encoding: "utf-8" }); - rl = createInterface({ - input: stream, - crlfDelay: Infinity, - }); - - for await (const line of rl) { - const trimmed = line.trim(); - if (!trimmed) continue; + const lines = await readJsonlPrefixLines(filePath, SESSION_METADATA_SCAN_LINE_LIMIT); + for (const line of lines) { try { - const parsed: unknown = JSON.parse(trimmed); + const parsed: unknown = JSON.parse(line); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) continue; const entry = parsed as CodexJsonlLine; - const payload = getCodexPayload(entry); if (entry.type === "session_meta") { @@ -353,49 +323,15 @@ async function streamCodexSessionData(filePath: string): Promise - findCodexSessionFile(session.workspacePath!, await getJsonlFiles()), + const workspacePath = session.workspacePath; + if (!workspacePath) return null; + return getCachedSessionFile(`cwd:${toComparablePath(workspacePath)}`, async () => + findCodexSessionFile(workspacePath, await getJsonlFiles()), ); } +function buildCodexSessionInfo(params: { + agentSessionId: string | null; + threadId: string | null; + model: string | null; +}): AgentSessionInfo { + const metadata: Record = {}; + if (params.threadId) metadata["codexThreadId"] = params.threadId; + if (params.model) metadata["codexModel"] = params.model; + + const info: AgentSessionInfo = { + summary: params.model ? `Codex session (${params.model})` : null, + summaryIsFallback: true, + agentSessionId: params.agentSessionId, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + }; + return info; +} + +function buildPersistedCodexSessionInfo(session: Session): AgentSessionInfo | null { + const threadId = getSessionMetadataString(session, "codexThreadId"); + const model = getSessionMetadataString(session, "codexModel"); + if (!threadId || (!model && !isTerminalSession(session))) return null; + + return buildCodexSessionInfo({ + agentSessionId: threadId, + threadId, + model, + }); +} + /** * Format a launch command for the host shell. On Windows the resolved binary * path is single-quoted by shellEscape (e.g. `'C:\Users\...\codex.cmd'`), and @@ -834,42 +801,20 @@ function createCodexAgent(): Agent { }, async getSessionInfo(session: Session): Promise { + const metadataInfo = buildPersistedCodexSessionInfo(session); + if (metadataInfo) return metadataInfo; + const sessionFile = await findCodexSessionFileCached(session); if (!sessionFile) return null; - // Stream the file line-by-line to avoid loading potentially huge - // rollout files (100 MB+) entirely into memory. - const data = await streamCodexSessionData(sessionFile); + const data = await readCodexSessionMetadata(sessionFile); if (!data) return null; - const agentSessionId = basename(sessionFile, ".jsonl"); - - let cost: CostEstimate | undefined; - const totalInputTokens = data.inputTokens + data.cachedTokens; - if (totalInputTokens > 0 || data.outputTokens > 0 || data.reasoningTokens > 0) { - const estimatedCostUsd = - (data.inputTokens / 1_000_000) * 2.5 + - (data.cachedTokens / 1_000_000) * 0.625 + - ((data.outputTokens + data.reasoningTokens) / 1_000_000) * 10.0; - cost = { - inputTokens: totalInputTokens, - outputTokens: data.outputTokens, - estimatedCostUsd, - }; - } - - return { - summary: data.model ? `Codex session (${data.model})` : null, - summaryIsFallback: true, - agentSessionId, - metadata: data.threadId - ? { - codexThreadId: data.threadId, - ...(data.model ? { codexModel: data.model } : {}), - } - : undefined, - cost, - }; + return buildCodexSessionInfo({ + agentSessionId: data.threadId ?? basename(sessionFile, ".jsonl"), + threadId: data.threadId, + model: data.model, + }); }, async getRestoreCommand(session: Session, project: ProjectConfig): Promise { @@ -882,9 +827,7 @@ function createCodexAgent(): Agent { const sessionFile = await findCodexSessionFileCached(session); if (!sessionFile) return null; - // Stream the file line-by-line to avoid loading potentially huge - // rollout files (100 MB+) entirely into memory. - const data = await streamCodexSessionData(sessionFile); + const data = await readCodexSessionMetadata(sessionFile); if (!data?.threadId) return null; threadId = data.threadId; model = data.model; diff --git a/packages/plugins/agent-cursor/src/index.test.ts b/packages/plugins/agent-cursor/src/index.test.ts index 6ddde8b84c..7d5590bfe8 100644 --- a/packages/plugins/agent-cursor/src/index.test.ts +++ b/packages/plugins/agent-cursor/src/index.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createActivitySignal, type Session, type RuntimeHandle, type AgentLaunchConfig } from "@aoagents/ao-core"; +import { + createActivitySignal, + type Session, + type RuntimeHandle, + type AgentLaunchConfig, +} from "@aoagents/ao-core"; // Mock fs/promises for getSessionInfo tests vi.mock("node:fs/promises", async (importOriginal) => { @@ -10,6 +15,7 @@ vi.mock("node:fs/promises", async (importOriginal) => { access: vi.fn().mockRejectedValue(new Error("ENOENT")), stat: vi.fn().mockRejectedValue(new Error("ENOENT")), lstat: vi.fn().mockResolvedValue({ isSymbolicLink: () => false }), + open: vi.fn().mockRejectedValue(new Error("ENOENT")), }; }); @@ -127,6 +133,24 @@ function mockTmuxWithProcess(processName: string, found = true) { }); } +function makeFakeFileHandle(content: string) { + const buf = Buffer.from(content, "utf-8"); + return { + read: vi + .fn() + .mockImplementation( + (buffer: Buffer, offset: number, length: number, position: number | null) => { + const start = position ?? 0; + if (start >= buf.length) return Promise.resolve({ bytesRead: 0, buffer }); + const bytesToCopy = Math.min(length, buf.length - start); + buf.copy(buffer, offset, start, start + bytesToCopy); + return Promise.resolve({ bytesRead: bytesToCopy, buffer }); + }, + ), + close: vi.fn().mockResolvedValue(undefined), + }; +} + beforeEach(() => { vi.clearAllMocks(); }); @@ -432,28 +456,27 @@ describe("getSessionInfo", () => { }); it("returns null when no cursor session file exists", async () => { - const { readFile } = await import("node:fs/promises"); - vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT")); + const { open } = await import("node:fs/promises"); + vi.mocked(open).mockRejectedValueOnce(new Error("ENOENT")); expect(await agent.getSessionInfo(makeSession())).toBeNull(); }); it("extracts summary from cursor chat file", async () => { - const { access, readFile } = await import("node:fs/promises"); - vi.mocked(access).mockResolvedValueOnce(undefined); - vi.mocked(readFile).mockResolvedValueOnce("# Cursor Session\n\nFix the login bug in auth.ts\n"); + const { open } = await import("node:fs/promises"); + vi.mocked(open).mockResolvedValueOnce( + makeFakeFileHandle("# Cursor Session\n\nFix the login bug in auth.ts\n") as never, + ); const info = await agent.getSessionInfo(makeSession()); expect(info).not.toBeNull(); expect(info!.summary).toBe("Fix the login bug in auth.ts"); expect(info!.summaryIsFallback).toBe(true); expect(info!.agentSessionId).toBeNull(); - expect(info!.cost).toBeUndefined(); }); it("truncates long summaries to 120 chars", async () => { - const { access, readFile } = await import("node:fs/promises"); + const { open } = await import("node:fs/promises"); const longMsg = "A".repeat(200); - vi.mocked(access).mockResolvedValueOnce(undefined); - vi.mocked(readFile).mockResolvedValueOnce(longMsg); + vi.mocked(open).mockResolvedValueOnce(makeFakeFileHandle(longMsg) as never); const info = await agent.getSessionInfo(makeSession()); expect(info!.summary).toHaveLength(123); // 120 + "..." expect(info!.summary!.endsWith("...")).toBe(true); @@ -467,10 +490,13 @@ describe("getRestoreCommand", () => { const agent = create(); it("returns null (cursor does not support session resume)", async () => { - const result = await agent.getRestoreCommand!( - makeSession(), - { name: "proj", repo: "o/r", path: "/p", defaultBranch: "main", sessionPrefix: "p" }, - ); + const result = await agent.getRestoreCommand!(makeSession(), { + name: "proj", + repo: "o/r", + path: "/p", + defaultBranch: "main", + sessionPrefix: "p", + }); expect(result).toBeNull(); }); }); diff --git a/packages/plugins/agent-cursor/src/index.ts b/packages/plugins/agent-cursor/src/index.ts index 83c63b8197..183df8b556 100644 --- a/packages/plugins/agent-cursor/src/index.ts +++ b/packages/plugins/agent-cursor/src/index.ts @@ -22,7 +22,7 @@ import { } from "@aoagents/ao-core"; import { execFile, execFileSync } from "node:child_process"; import { promisify } from "node:util"; -import { stat, access, readFile, lstat } from "node:fs/promises"; +import { stat, access, open, lstat } from "node:fs/promises"; import { lstatSync, constants } from "node:fs"; import { join, resolve } from "node:path"; @@ -102,7 +102,15 @@ async function extractCursorSummary(workspacePath: string): Promise { const agent = create(); it("generates base command with --work-dir", () => { - expect(agent.getLaunchCommand(makeLaunchConfig())).toBe( - "kimi --work-dir '/workspace/repo'", - ); + expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("kimi --work-dir '/workspace/repo'"); }); it("adds --yolo when permissions=permissionless", () => { @@ -381,9 +379,7 @@ describe("getEnvironment", () => { it("sets AO_ISSUE_ID only when provided", () => { expect(agent.getEnvironment(makeLaunchConfig()).AO_ISSUE_ID).toBeUndefined(); - expect(agent.getEnvironment(makeLaunchConfig({ issueId: "GH-42" })).AO_ISSUE_ID).toBe( - "GH-42", - ); + expect(agent.getEnvironment(makeLaunchConfig({ issueId: "GH-42" })).AO_ISSUE_ID).toBe("GH-42"); }); // PATH and GH_PATH are not set here — session-manager injects them for @@ -464,11 +460,7 @@ describe("detectActivity", () => { // UI re-renders `kimi>` on the last line as part of the prompt chrome. // Old ordering (idle-first) misclassified this as idle and left the // session hanging. Actionable states MUST win. - const output = [ - "Allow file write?", - "(Y)es/(N)o", - "kimi> ", - ].join("\n"); + const output = ["Allow file write?", "(Y)es/(N)o", "kimi> "].join("\n"); expect(agent.detectActivity(output)).toBe("waiting_input"); }); @@ -739,9 +731,7 @@ describe("getActivityState", () => { ); mockTmuxWithProcess("kimi"); - const info = await agent.getSessionInfo( - makeSession({ workspacePath: realWorkspace }), - ); + const info = await agent.getSessionInfo(makeSession({ workspacePath: realWorkspace })); expect(info?.agentSessionId).toBe("pinned-uuid"); }); @@ -751,9 +741,7 @@ describe("getActivityState", () => { writeKimiSession(realWorkspace, "ao-spawned"); mockTmuxWithProcess("kimi"); - const info = await agent.getSessionInfo( - makeSession({ workspacePath: realWorkspace }), - ); + const info = await agent.getSessionInfo(makeSession({ workspacePath: realWorkspace })); expect(info?.agentSessionId).toBe("ao-spawned"); const pin = JSON.parse( @@ -782,9 +770,7 @@ describe("getActivityState", () => { mockTmuxWithProcess("kimi"); _resetSessionMatchCache(); - const info = await agent.getSessionInfo( - makeSession({ workspacePath: realWorkspace }), - ); + const info = await agent.getSessionInfo(makeSession({ workspacePath: realWorkspace })); expect(info?.agentSessionId).toBe("ao-original"); }); @@ -826,9 +812,7 @@ describe("getActivityState", () => { }), ); - const info = await agent.getSessionInfo( - makeSession({ workspacePath: realWorkspace }), - ); + const info = await agent.getSessionInfo(makeSession({ workspacePath: realWorkspace })); expect(info?.agentSessionId).toBe("ao-launched-uuid"); }); @@ -869,9 +853,7 @@ describe("getActivityState", () => { writeKimiSession(realWorkspace, "ao-spawned"); mockTmuxWithProcess("kimi"); - const info = await agent.getSessionInfo( - makeSession({ workspacePath: realWorkspace }), - ); + const info = await agent.getSessionInfo(makeSession({ workspacePath: realWorkspace })); expect(info?.agentSessionId).toBe("ao-spawned"); }); @@ -894,9 +876,7 @@ describe("getActivityState", () => { writeKimiSession(realWorkspace, "kimi-just-created-this"); mockTmuxWithProcess("kimi"); - const info = await agent.getSessionInfo( - makeSession({ workspacePath: realWorkspace }), - ); + const info = await agent.getSessionInfo(makeSession({ workspacePath: realWorkspace })); // The newly-created UUID must NOT have been treated as pre-existing — // pre-launch baseline didn't see it. Discovery must attach to it. expect(info?.agentSessionId).toBe("kimi-just-created-this"); @@ -939,9 +919,7 @@ describe("getActivityState", () => { ); mockTmuxWithProcess("kimi"); - const info = await agent.getSessionInfo( - makeSession({ workspacePath: realWorkspace }), - ); + const info = await agent.getSessionInfo(makeSession({ workspacePath: realWorkspace })); expect(info).toBeNull(); }); @@ -1104,7 +1082,6 @@ describe("getSessionInfo", () => { expect(info).not.toBeNull(); expect(info!.agentSessionId).toBe("6ec34626-aedf-4659-a061-c5fbfa4cf166"); expect(info!.summaryIsFallback).toBe(true); - expect(info!.cost).toBeUndefined(); }); it("extracts the first user input from wire.jsonl as a summary", async () => { @@ -1159,9 +1136,7 @@ describe("getSessionInfo", () => { writeFileSync( join(fakeHome, ".kimi", "kimi.json"), JSON.stringify({ - work_dirs: [ - { path: realWorkspace, kaos: "local", last_session_id: "older-uuid" }, - ], + work_dirs: [{ path: realWorkspace, kaos: "local", last_session_id: "older-uuid" }], }), ); @@ -1183,15 +1158,11 @@ describe("getSessionInfo", () => { writeFileSync( join(fakeHome, ".kimi", "kimi.json"), JSON.stringify({ - work_dirs: [ - { path: realWorkspace, kaos: "local", last_session_id: null }, - ], + work_dirs: [{ path: realWorkspace, kaos: "local", last_session_id: null }], }), ); - const info = await agent.getSessionInfo( - makeSession({ workspacePath: realWorkspace }), - ); + const info = await agent.getSessionInfo(makeSession({ workspacePath: realWorkspace })); expect(info?.agentSessionId).toBe("only-uuid"); }); @@ -1199,9 +1170,7 @@ describe("getSessionInfo", () => { // No kimi.json exists — hash-based discovery should still work. writeKimiSession(workspace, "hash-found-uuid"); - const info = await agent.getSessionInfo( - makeSession({ workspacePath: workspace }), - ); + const info = await agent.getSessionInfo(makeSession({ workspacePath: workspace })); expect(info?.agentSessionId).toBe("hash-found-uuid"); }); @@ -1235,15 +1204,11 @@ describe("getSessionInfo", () => { writeFileSync( join(fakeHome, ".kimi", "kimi.json"), JSON.stringify({ - work_dirs: [ - { path: realWorkspace, kaos: "local", last_session_id: "stale-uuid" }, - ], + work_dirs: [{ path: realWorkspace, kaos: "local", last_session_id: "stale-uuid" }], }), ); - const info = await agent.getSessionInfo( - makeSession({ workspacePath: realWorkspace }), - ); + const info = await agent.getSessionInfo(makeSession({ workspacePath: realWorkspace })); expect(info?.agentSessionId).toBe("ao-spawned"); // And the AO pin file must record "ao-spawned", not "stale-uuid" — @@ -1272,9 +1237,7 @@ describe("getSessionInfo", () => { writeFileSync( join(fakeHome, ".kimi", "kimi.json"), JSON.stringify({ - work_dirs: [ - { path: realWorkspace, kaos: "local", last_session_id: "old-uuid" }, - ], + work_dirs: [{ path: realWorkspace, kaos: "local", last_session_id: "old-uuid" }], }), ); @@ -1315,7 +1278,10 @@ describe("getSessionInfo", () => { // wire.jsonl and must refuse. writeFileSync(join(sessionDir, "context.jsonl"), '{"role":"_system_prompt"}\n'); const decoy = join(fakeHome, "decoy-wire.txt"); - writeFileSync(decoy, '{"timestamp":1,"message":{"type":"TurnBegin","payload":{"user_input":"leaked"}}}\n'); + writeFileSync( + decoy, + '{"timestamp":1,"message":{"type":"TurnBegin","payload":{"user_input":"leaked"}}}\n', + ); symlinkSync(decoy, join(sessionDir, "wire.jsonl")); const info = await agent.getSessionInfo(makeSession({ workspacePath: workspace })); @@ -1427,7 +1393,10 @@ describe("workspace hooks", () => { const agent = create(); it("setupWorkspaceHooks delegates to setupPathWrapperWorkspace", async () => { - await agent.setupWorkspaceHooks!("/workspace/test", { dataDir: "/tmp/ao-data", sessionId: "s" }); + await agent.setupWorkspaceHooks!("/workspace/test", { + dataDir: "/tmp/ao-data", + sessionId: "s", + }); expect(mockSetupPathWrapperWorkspace).toHaveBeenCalledWith("/workspace/test"); }); @@ -1486,8 +1455,7 @@ describe("detect", () => { // contains plain "kimi" but no kimi-cli / kimi-code / moonshot marker. const { execFileSync } = await import("node:child_process"); vi.mocked(execFileSync).mockImplementationOnce( - () => - "kimi v0.1 — keyboard input manager\n" as unknown as ReturnType, + () => "kimi v0.1 — keyboard input manager\n" as unknown as ReturnType, ); expect(detect()).toBe(false); }); diff --git a/packages/plugins/agent-kimicode/src/index.ts b/packages/plugins/agent-kimicode/src/index.ts index 2711bafa4e..fa72a1fc68 100644 --- a/packages/plugins/agent-kimicode/src/index.ts +++ b/packages/plugins/agent-kimicode/src/index.ts @@ -55,7 +55,11 @@ async function extractKimiSummary(sessionDir: string): Promise { let stream: ReturnType | null = null; let rl: ReturnType | null = null; try { - stream = createReadStream(wirePath, { encoding: "utf-8" }); + stream = createReadStream(wirePath, { + encoding: "utf-8", + start: 0, + end: SUMMARY_SCAN_BYTE_LIMIT - 1, + }); rl = createInterface({ input: stream, crlfDelay: Infinity, @@ -186,9 +190,7 @@ function createKimicodeAgent(): Agent { let combinedPrompt = config.prompt ?? ""; if (config.systemPromptFile) { const sysContent = readFileSync(config.systemPromptFile, "utf-8"); - combinedPrompt = combinedPrompt - ? `${sysContent}\n\n---\n\n${combinedPrompt}` - : sysContent; + combinedPrompt = combinedPrompt ? `${sysContent}\n\n---\n\n${combinedPrompt}` : sysContent; } if (combinedPrompt) { parts.push("--prompt", shellEscape(combinedPrompt)); @@ -234,7 +236,8 @@ function createKimicodeAgent(): Agent { // 2. blocked — hard errors surfaced to the terminal. Line-anchored to // skip narration ("Earlier I failed to connect, then retried"). if (/^\s*error:/im.test(tail)) return "blocked"; - if (/^\s*(?:error:\s*)?failed to (connect|authenticate|load)\b/im.test(tail)) return "blocked"; + if (/^\s*(?:error:\s*)?failed to (connect|authenticate|load)\b/im.test(tail)) + return "blocked"; // 3. idle — only when nothing actionable is visible and the tail is a // bare prompt. Generic shell/REPL prompt… @@ -373,8 +376,7 @@ function createKimicodeAgent(): Agent { const match = await findKimiSessionMatch(session); if (!match) return null; - // Best-effort summary: first user input from wire.jsonl. Kimi does not - // store a title, model, or cost breakdown on disk. + // Best-effort summary: first user input from a bounded wire.jsonl prefix. const summary = await extractKimiSummary(match.dir); return { diff --git a/packages/plugins/agent-opencode/src/index.ts b/packages/plugins/agent-opencode/src/index.ts index 6c9a9ae258..b52318710a 100644 --- a/packages/plugins/agent-opencode/src/index.ts +++ b/packages/plugins/agent-opencode/src/index.ts @@ -406,7 +406,6 @@ function createOpenCodeAgent(): Agent { summary: targetSession.title ?? null, summaryIsFallback: true, agentSessionId: targetSession.id, - // OpenCode doesn't expose token/cost data in session list }; },