From 9b93dfad18e3b0b90d7af43720d1515776708df3 Mon Sep 17 00:00:00 2001 From: yyovil Date: Fri, 22 May 2026 02:52:09 +0530 Subject: [PATCH 1/5] fix(agent-codex): avoid JSONL parsing for persisted terminal sessions --- .../plugins/agent-codex/src/index.test.ts | 165 +++++++++++++++++- packages/plugins/agent-codex/src/index.ts | 144 +++++++++++---- 2 files changed, 266 insertions(+), 43 deletions(-) diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index 5499873a1d..d4ecffe119 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -118,6 +118,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 +179,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") { @@ -1022,11 +1052,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(); }); @@ -1077,6 +1102,132 @@ describe("getSessionInfo", () => { expect(mockOpen).not.toHaveBeenCalled(); }); + it("returns metadata-only info for terminal sessions without streaming JSONL", 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(); + expect(mockCreateReadStream).not.toHaveBeenCalled(); + }); + + it("does not stream JSONL for terminal sessions that only have codexThreadId", async () => { + const result = await agent.getSessionInfo( + makeSession({ + status: "killed", + activity: "exited", + metadata: { codexThreadId: "thread-without-model" }, + }), + ); + + expect(result).toMatchObject({ + summary: null, + summaryIsFallback: true, + agentSessionId: "thread-without-model", + metadata: { codexThreadId: "thread-without-model" }, + }); + expect(mockReaddir).not.toHaveBeenCalled(); + expect(mockOpen).not.toHaveBeenCalled(); + expect(mockCreateReadStream).not.toHaveBeenCalled(); + }); + + it("still streams JSONL 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"]); + setupMockStream(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: "rollout-2026-05-22T00-00-00-thread-live-no-model", + metadata: { codexThreadId: "thread-live-no-model", codexModel: "gpt-5.5" }, + }); + expect(mockCreateReadStream).toHaveBeenCalledTimes(1); + expect(mockOpen).not.toHaveBeenCalled(); + }); + + it("caches streamed session data for repeated getSessionInfo calls", async () => { + const sessionContent = jsonl( + { + type: "session_meta", + payload: { id: "thread-parse-cache" }, + }, + { + type: "turn_context", + payload: { model: "gpt-5.3-codex" }, + }, + ); + + mockReaddir.mockResolvedValue(["rollout-thread-parse-cache.jsonl"]); + mockCreateReadStream.mockImplementation(() => makeContentStream(sessionContent)); + + const session = makeSession({ + metadata: { codexThreadId: "thread-parse-cache" }, + }); + + const first = await agent.getSessionInfo(session); + const second = await agent.getSessionInfo(session); + + expect(first?.summary).toBe("Codex session (gpt-5.3-codex)"); + expect(second?.summary).toBe("Codex session (gpt-5.3-codex)"); + expect(mockReaddir).toHaveBeenCalledTimes(1); + expect(mockCreateReadStream).toHaveBeenCalledTimes(1); + }); + it("caches codexThreadId filename lookups by thread id", async () => { const sessionContent = jsonl({ type: "session_meta", @@ -1517,10 +1668,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", diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index 770c6a5050..c949bbbef6 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -533,10 +533,20 @@ function appendNoUpdateCheckFlag(parts: string[]): void { /** TTL for session file path cache (ms). Prevents redundant filesystem scans * when getActivityState and getSessionInfo are called in the same refresh cycle. */ const SESSION_FILE_CACHE_TTL_MS = 30_000; +const SESSION_DATA_CACHE_TTL_MS = 30_000; /** Module-level session file cache shared across the agent instance lifetime. * Keyed by Codex thread id when available, otherwise workspace path. */ const sessionFileCache = new Map(); +const sessionDataCache = new Map(); +const sessionDataInFlight = new Map>(); +const TERMINAL_OR_HISTORICAL_STATUSES = new Set([ + "killed", + "done", + "merged", + "terminated", + "cleanup", +]); function getSessionMetadataString(session: Session, key: string): string | null { const value = session.metadata?.[key]; @@ -581,6 +591,93 @@ async function findCodexSessionFileCached(session: Session): Promise = {}; + 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, + }; + if (params.cost) info.cost = params.cost; + return info; +} + +function buildMetadataOnlySessionInfo(session: Session): AgentSessionInfo | null { + const threadId = getSessionMetadataString(session, "codexThreadId"); + if (!threadId) return null; + + const model = getSessionMetadataString(session, "codexModel"); + if (!model && !isTerminalOrRuntimeMissing(session)) { + return null; + } + + return buildCodexSessionInfo({ + agentSessionId: threadId, + threadId, + model, + }); +} + +async function getCachedCodexSessionData(filePath: string): Promise { + const cached = sessionDataCache.get(filePath); + if (cached && Date.now() < cached.expiry) { + return cached.data; + } + + const inFlight = sessionDataInFlight.get(filePath); + if (inFlight) return inFlight; + + const promise = streamCodexSessionData(filePath).then((data) => { + sessionDataCache.set(filePath, { + data, + expiry: Date.now() + SESSION_DATA_CACHE_TTL_MS, + }); + return data; + }); + sessionDataInFlight.set(filePath, promise); + + try { + return await promise; + } finally { + sessionDataInFlight.delete(filePath); + } +} + /** * 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 +931,21 @@ function createCodexAgent(): Agent { }, async getSessionInfo(session: Session): Promise { + const metadataInfo = buildMetadataOnlySessionInfo(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 getCachedCodexSessionData(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: basename(sessionFile, ".jsonl"), + threadId: data.threadId, + model: data.model, + cost: calculateCost(data), + }); }, async getRestoreCommand(session: Session, project: ProjectConfig): Promise { @@ -882,9 +958,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 getCachedCodexSessionData(sessionFile); if (!data?.threadId) return null; threadId = data.threadId; model = data.model; @@ -943,6 +1017,8 @@ export function create(): Agent { /** @internal Clear the session file cache. Exported for testing only. */ export function _resetSessionFileCache(): void { sessionFileCache.clear(); + sessionDataCache.clear(); + sessionDataInFlight.clear(); } export { CodexAppServerClient } from "./app-server-client.js"; From d827e4a286e33ce56c8021ac8569e770f7c50b33 Mon Sep 17 00:00:00 2001 From: yyovil Date: Fri, 22 May 2026 02:56:56 +0530 Subject: [PATCH 2/5] fix(agent-codex): keep live session info detailed --- .../plugins/agent-codex/src/index.test.ts | 41 +++++++++++++++++++ packages/plugins/agent-codex/src/index.ts | 9 +--- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index d4ecffe119..264155d9c1 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -1200,6 +1200,47 @@ describe("getSessionInfo", () => { expect(mockOpen).not.toHaveBeenCalled(); }); + it("still streams JSONL for live sessions that already have codexModel metadata", async () => { + const sessionContent = jsonl( + { + type: "session_meta", + payload: { id: "thread-live-with-model" }, + }, + { + type: "turn_context", + payload: { model: "gpt-5.5" }, + }, + { + type: "event_msg", + msg: { + type: "token_count", + input_tokens: 100, + output_tokens: 50, + }, + }, + ); + + mockReaddir.mockResolvedValue(["rollout-2026-05-22T00-00-00-thread-live-with-model.jsonl"]); + setupMockStream(sessionContent); + + 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.5)", + agentSessionId: "rollout-2026-05-22T00-00-00-thread-live-with-model", + metadata: { codexThreadId: "thread-live-with-model", codexModel: "gpt-5.5" }, + cost: { inputTokens: 100, outputTokens: 50 }, + }); + expect(mockCreateReadStream).toHaveBeenCalledTimes(1); + expect(mockOpen).not.toHaveBeenCalled(); + }); + it("caches streamed session data for repeated getSessionInfo calls", async () => { const sessionContent = jsonl( { diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index c949bbbef6..c0f50d12de 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -639,17 +639,12 @@ function buildCodexSessionInfo(params: { function buildMetadataOnlySessionInfo(session: Session): AgentSessionInfo | null { const threadId = getSessionMetadataString(session, "codexThreadId"); - if (!threadId) return null; - - const model = getSessionMetadataString(session, "codexModel"); - if (!model && !isTerminalOrRuntimeMissing(session)) { - return null; - } + if (!threadId || !isTerminalOrRuntimeMissing(session)) return null; return buildCodexSessionInfo({ agentSessionId: threadId, threadId, - model, + model: getSessionMetadataString(session, "codexModel"), }); } From f14e1826260020d15adf47c44cf6c106309097df Mon Sep 17 00:00:00 2001 From: yyovil Date: Fri, 22 May 2026 03:05:53 +0530 Subject: [PATCH 3/5] fix(agent-codex): align terminal fast path with core --- .../plugins/agent-codex/src/index.test.ts | 110 +++++++++++++++++- packages/plugins/agent-codex/src/index.ts | 34 ++---- 2 files changed, 115 insertions(+), 29 deletions(-) diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index 264155d9c1..55e935b3f1 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -1097,7 +1097,7 @@ 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).not.toHaveBeenCalled(); }); @@ -1154,6 +1154,15 @@ describe("getSessionInfo", () => { 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(), + }, + }, }), ); @@ -1168,6 +1177,50 @@ describe("getSessionInfo", () => { expect(mockCreateReadStream).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(); + expect(mockCreateReadStream).not.toHaveBeenCalled(); + }); + it("still streams JSONL for live codexThreadId sessions missing model metadata", async () => { const sessionContent = jsonl( { @@ -1193,7 +1246,7 @@ describe("getSessionInfo", () => { expect(result).toMatchObject({ summary: "Codex session (gpt-5.5)", - agentSessionId: "rollout-2026-05-22T00-00-00-thread-live-no-model", + agentSessionId: "thread-live-no-model", metadata: { codexThreadId: "thread-live-no-model", codexModel: "gpt-5.5" }, }); expect(mockCreateReadStream).toHaveBeenCalledTimes(1); @@ -1233,7 +1286,7 @@ describe("getSessionInfo", () => { expect(result).toMatchObject({ summary: "Codex session (gpt-5.5)", - agentSessionId: "rollout-2026-05-22T00-00-00-thread-live-with-model", + agentSessionId: "thread-live-with-model", metadata: { codexThreadId: "thread-live-with-model", codexModel: "gpt-5.5" }, cost: { inputTokens: 100, outputTokens: 50 }, }); @@ -1332,7 +1385,7 @@ 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(); }); @@ -1468,7 +1521,7 @@ describe("getSessionInfo", () => { 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); @@ -1628,6 +1681,53 @@ describe("getSessionInfo", () => { expect(destroySpy).toHaveBeenCalledTimes(1); }); + it("dedupes concurrent failed streams without caching the failure", async () => { + const sessionContent = jsonl( + { + type: "session_meta", + payload: { id: "thread-failure-retry" }, + }, + { + type: "turn_context", + payload: { model: "gpt-5.5" }, + }, + ); + mockReaddir.mockResolvedValue(["rollout-thread-failure-retry.jsonl"]); + mockCreateReadStream.mockImplementation(() => makeContentStream(sessionContent)); + + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const closeSpy = vi.fn(); + mockCreateInterface.mockImplementationOnce(() => ({ + close: closeSpy, + async *[Symbol.asyncIterator]() { + await gate; + throw new Error("aborted"); + }, + })); + + const session = makeSession({ + metadata: { codexThreadId: "thread-failure-retry" }, + }); + + const first = agent.getSessionInfo(session); + await Promise.resolve(); + await Promise.resolve(); + const second = agent.getSessionInfo(session); + + release(); + await expect(first).resolves.toBeNull(); + await expect(second).resolves.toBeNull(); + expect(mockCreateReadStream).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalledTimes(1); + + const retry = await agent.getSessionInfo(session); + expect(retry?.summary).toBe("Codex session (gpt-5.5)"); + expect(mockCreateReadStream).toHaveBeenCalledTimes(2); + }); + it("skips session files when stat throws", async () => { const content = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }); mockReaddir.mockResolvedValue(["sess.jsonl"]); diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index c0f50d12de..cc34ef842b 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -10,6 +10,7 @@ import { recordTerminalActivity, isWindows, PROCESS_PROBE_INDETERMINATE, + isTerminalSession, type Agent, type AgentSessionInfo, type AgentLaunchConfig, @@ -538,15 +539,8 @@ const SESSION_DATA_CACHE_TTL_MS = 30_000; /** Module-level session file cache shared across the agent instance lifetime. * Keyed by Codex thread id when available, otherwise workspace path. */ const sessionFileCache = new Map(); -const sessionDataCache = new Map(); +const sessionDataCache = new Map(); const sessionDataInFlight = new Map>(); -const TERMINAL_OR_HISTORICAL_STATUSES = new Set([ - "killed", - "done", - "merged", - "terminated", - "cleanup", -]); function getSessionMetadataString(session: Session, key: string): string | null { const value = session.metadata?.[key]; @@ -591,16 +585,6 @@ async function findCodexSessionFileCached(session: Session): Promise { - sessionDataCache.set(filePath, { - data, - expiry: Date.now() + SESSION_DATA_CACHE_TTL_MS, - }); + if (data) { + sessionDataCache.set(filePath, { + data, + expiry: Date.now() + SESSION_DATA_CACHE_TTL_MS, + }); + } return data; }); sessionDataInFlight.set(filePath, promise); @@ -936,7 +922,7 @@ function createCodexAgent(): Agent { if (!data) return null; return buildCodexSessionInfo({ - agentSessionId: basename(sessionFile, ".jsonl"), + agentSessionId: data.threadId ?? basename(sessionFile, ".jsonl"), threadId: data.threadId, model: data.model, cost: calculateCost(data), From 63ad054566e706eed989d1d9ce0b696bda58a4b6 Mon Sep 17 00:00:00 2001 From: yyovil Date: Fri, 22 May 2026 03:08:04 +0530 Subject: [PATCH 4/5] test(agent-codex): satisfy async iterator lint --- packages/plugins/agent-codex/src/index.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index 55e935b3f1..8590c38282 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -1702,9 +1702,13 @@ describe("getSessionInfo", () => { const closeSpy = vi.fn(); mockCreateInterface.mockImplementationOnce(() => ({ close: closeSpy, - async *[Symbol.asyncIterator]() { - await gate; - throw new Error("aborted"); + [Symbol.asyncIterator]() { + return { + async next() { + await gate; + throw new Error("aborted"); + }, + }; }, })); From e18afed9076a10450ea5177cbf101e896efb596b Mon Sep 17 00:00:00 2001 From: yyovil Date: Fri, 22 May 2026 05:05:37 +0530 Subject: [PATCH 5/5] refactor(agents): remove session token cost enrichment --- artifacts/architecture-design.md | 1 - packages/core/src/session-manager.ts | 2 +- packages/core/src/types.ts | 12 +- .../plugins/agent-aider/src/index.test.ts | 38 ++- packages/plugins/agent-aider/src/index.ts | 13 +- .../agent-claude-code/src/index.test.ts | 195 +++++------- .../plugins/agent-claude-code/src/index.ts | 115 +------ .../plugins/agent-codex/src/index.test.ts | 301 +++--------------- packages/plugins/agent-codex/src/index.ts | 184 ++--------- .../plugins/agent-cursor/src/index.test.ts | 54 +++- packages/plugins/agent-cursor/src/index.ts | 13 +- .../plugins/agent-kimicode/src/index.test.ts | 84 ++--- packages/plugins/agent-kimicode/src/index.ts | 16 +- packages/plugins/agent-opencode/src/index.ts | 1 - 14 files changed, 288 insertions(+), 741 deletions(-) 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 8590c38282..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, @@ -230,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(() => { @@ -255,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("")); }); // ========================================================================= @@ -721,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(); @@ -1070,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", @@ -1087,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({ @@ -1099,10 +1062,10 @@ describe("getSessionInfo", () => { expect(result).not.toBeNull(); expect(result!.agentSessionId).toBe("thread-fast-info"); expect(result!.summary).toBe("Codex session (gpt-5.5)"); - expect(mockOpen).not.toHaveBeenCalled(); + expect(mockOpen).toHaveBeenCalledTimes(1); }); - it("returns metadata-only info for terminal sessions without streaming JSONL", async () => { + it("returns metadata-only info for terminal sessions without JSONL reads", async () => { const result = await agent.getSessionInfo( makeSession({ status: "terminated", @@ -1145,10 +1108,9 @@ describe("getSessionInfo", () => { }); expect(mockReaddir).not.toHaveBeenCalled(); expect(mockOpen).not.toHaveBeenCalled(); - expect(mockCreateReadStream).not.toHaveBeenCalled(); }); - it("does not stream JSONL for terminal sessions that only have codexThreadId", async () => { + it("does not read JSONL for terminal sessions that only have codexThreadId", async () => { const result = await agent.getSessionInfo( makeSession({ status: "killed", @@ -1174,7 +1136,6 @@ describe("getSessionInfo", () => { }); expect(mockReaddir).not.toHaveBeenCalled(); expect(mockOpen).not.toHaveBeenCalled(); - expect(mockCreateReadStream).not.toHaveBeenCalled(); }); it("uses core terminal lifecycle signals for the metadata-only fast path", async () => { @@ -1218,10 +1179,9 @@ describe("getSessionInfo", () => { expect(runtimeExitedResult?.agentSessionId).toBe("thread-runtime-exited"); expect(mockReaddir).not.toHaveBeenCalled(); expect(mockOpen).not.toHaveBeenCalled(); - expect(mockCreateReadStream).not.toHaveBeenCalled(); }); - it("still streams JSONL for live codexThreadId sessions missing model metadata", async () => { + it("uses bounded metadata reads for live codexThreadId sessions missing model metadata", async () => { const sessionContent = jsonl( { type: "session_meta", @@ -1234,7 +1194,7 @@ describe("getSessionInfo", () => { ); mockReaddir.mockResolvedValue(["rollout-2026-05-22T00-00-00-thread-live-no-model.jsonl"]); - setupMockStream(sessionContent); + setupMockOpen(sessionContent); const result = await agent.getSessionInfo( makeSession({ @@ -1249,33 +1209,10 @@ describe("getSessionInfo", () => { agentSessionId: "thread-live-no-model", metadata: { codexThreadId: "thread-live-no-model", codexModel: "gpt-5.5" }, }); - expect(mockCreateReadStream).toHaveBeenCalledTimes(1); - expect(mockOpen).not.toHaveBeenCalled(); + expect(mockOpen).toHaveBeenCalled(); }); - it("still streams JSONL for live sessions that already have codexModel metadata", async () => { - const sessionContent = jsonl( - { - type: "session_meta", - payload: { id: "thread-live-with-model" }, - }, - { - type: "turn_context", - payload: { model: "gpt-5.5" }, - }, - { - type: "event_msg", - msg: { - type: "token_count", - input_tokens: 100, - output_tokens: 50, - }, - }, - ); - - mockReaddir.mockResolvedValue(["rollout-2026-05-22T00-00-00-thread-live-with-model.jsonl"]); - setupMockStream(sessionContent); - + it("returns metadata-only info for live sessions that already have codexModel metadata", async () => { const result = await agent.getSessionInfo( makeSession({ status: "working", @@ -1285,43 +1222,14 @@ describe("getSessionInfo", () => { ); expect(result).toMatchObject({ - summary: "Codex session (gpt-5.5)", + summary: "Codex session (gpt-5.4)", agentSessionId: "thread-live-with-model", - metadata: { codexThreadId: "thread-live-with-model", codexModel: "gpt-5.5" }, - cost: { inputTokens: 100, outputTokens: 50 }, + metadata: { codexThreadId: "thread-live-with-model", codexModel: "gpt-5.4" }, }); - expect(mockCreateReadStream).toHaveBeenCalledTimes(1); + expect(mockReaddir).not.toHaveBeenCalled(); expect(mockOpen).not.toHaveBeenCalled(); }); - it("caches streamed session data for repeated getSessionInfo calls", async () => { - const sessionContent = jsonl( - { - type: "session_meta", - payload: { id: "thread-parse-cache" }, - }, - { - type: "turn_context", - payload: { model: "gpt-5.3-codex" }, - }, - ); - - mockReaddir.mockResolvedValue(["rollout-thread-parse-cache.jsonl"]); - mockCreateReadStream.mockImplementation(() => makeContentStream(sessionContent)); - - const session = makeSession({ - metadata: { codexThreadId: "thread-parse-cache" }, - }); - - const first = await agent.getSessionInfo(session); - const second = await agent.getSessionInfo(session); - - expect(first?.summary).toBe("Codex session (gpt-5.3-codex)"); - expect(second?.summary).toBe("Codex session (gpt-5.3-codex)"); - expect(mockReaddir).toHaveBeenCalledTimes(1); - expect(mockCreateReadStream).toHaveBeenCalledTimes(1); - }); - it("caches codexThreadId filename lookups by thread id", async () => { const sessionContent = jsonl({ type: "session_meta", @@ -1331,7 +1239,7 @@ describe("getSessionInfo", () => { }); mockReaddir.mockResolvedValue(["rollout-thread-cached-info.jsonl"]); - mockCreateReadStream.mockImplementation(() => makeContentStream(sessionContent)); + setupMockOpen(sessionContent); const first = await agent.getSessionInfo( makeSession({ @@ -1349,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 () => { @@ -1371,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( @@ -1387,7 +1295,7 @@ describe("getSessionInfo", () => { expect(result).not.toBeNull(); 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 () => { @@ -1402,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(); @@ -1424,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" })); @@ -1434,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" }, { @@ -1461,7 +1366,6 @@ describe("getSessionInfo", () => { mockReaddir.mockResolvedValue(["session-123.jsonl"]); setupMockOpen(sessionContent); - setupMockStream(sessionContent); mockReadFile.mockResolvedValue(sessionContent); mockStat.mockResolvedValue({ mtimeMs: 1000 }); @@ -1471,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 () => { @@ -1515,7 +1412,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" })); @@ -1523,9 +1419,6 @@ describe("getSessionInfo", () => { expect(result).not.toBeNull(); 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 () => { @@ -1540,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" })); @@ -1564,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 }); @@ -1590,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 () => { @@ -1609,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" } }, @@ -1630,106 +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("dedupes concurrent failed streams without caching the failure", async () => { - const sessionContent = jsonl( - { - type: "session_meta", - payload: { id: "thread-failure-retry" }, - }, - { - type: "turn_context", - payload: { model: "gpt-5.5" }, - }, - ); - mockReaddir.mockResolvedValue(["rollout-thread-failure-retry.jsonl"]); - mockCreateReadStream.mockImplementation(() => makeContentStream(sessionContent)); - - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - const closeSpy = vi.fn(); - mockCreateInterface.mockImplementationOnce(() => ({ - close: closeSpy, - [Symbol.asyncIterator]() { - return { - async next() { - await gate; - throw new Error("aborted"); - }, - }; - }, - })); - - const session = makeSession({ - metadata: { codexThreadId: "thread-failure-retry" }, - }); - - const first = agent.getSessionInfo(session); - await Promise.resolve(); - await Promise.resolve(); - const second = agent.getSessionInfo(session); - - release(); - await expect(first).resolves.toBeNull(); - await expect(second).resolves.toBeNull(); - expect(mockCreateReadStream).toHaveBeenCalledTimes(1); - expect(closeSpy).toHaveBeenCalledTimes(1); - - const retry = await agent.getSessionInfo(session); - expect(retry?.summary).toBe("Codex session (gpt-5.5)"); - expect(mockCreateReadStream).toHaveBeenCalledTimes(2); }); it("skips session files when stat throws", async () => { @@ -1748,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 () => { @@ -1776,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" })); @@ -1789,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 }); @@ -1798,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); }); }); @@ -1847,7 +1635,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); const session = makeSession({ workspacePath: "/workspace/test" }); @@ -1862,7 +1649,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); @@ -1909,7 +1695,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); const session = makeSession({ workspacePath: "/workspace/test" }); @@ -1931,7 +1716,6 @@ describe("getRestoreCommand", () => { }); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); const session = makeSession({ workspacePath: "/workspace/test" }); @@ -1948,7 +1732,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); @@ -1974,7 +1757,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); @@ -1999,7 +1781,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); @@ -2022,7 +1803,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); @@ -2045,7 +1825,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); @@ -2067,7 +1846,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); @@ -2095,7 +1873,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); @@ -2118,7 +1895,6 @@ describe("getRestoreCommand", () => { ); mockReaddir.mockResolvedValue(["sess.jsonl"]); setupMockOpen(content); - setupMockStream(content); mockReadFile.mockResolvedValue(content); mockStat.mockResolvedValue({ mtimeMs: 1000 }); @@ -2131,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 cc34ef842b..730982623b 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -16,7 +16,6 @@ import { type AgentLaunchConfig, type ActivityState, type ActivityDetection, - type CostEstimate, type PluginModule, type ProcessProbeResult, type ProjectConfig, @@ -25,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); @@ -59,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; @@ -79,10 +69,6 @@ interface CodexJsonlPayload extends CodexTokenUsage { content?: string; role?: string; type?: string; - info?: { - total_token_usage?: CodexTokenUsage; - last_token_usage?: CodexTokenUsage; - }; } /** @@ -93,7 +79,6 @@ interface CodexJsonlPayload extends CodexTokenUsage { interface CodexJsonlLine extends CodexJsonlPayload { type?: string; payload?: CodexJsonlPayload; - msg?: CodexTokenUsage & { type?: string }; } function getCodexPayload(entry: CodexJsonlLine): CodexJsonlPayload { @@ -143,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. @@ -156,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(); @@ -165,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) { @@ -289,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") { @@ -354,49 +323,15 @@ async function streamCodexSessionData(filePath: string): Promise(); -const sessionDataCache = new Map(); -const sessionDataInFlight = new Map>(); function getSessionMetadataString(session: Session, key: string): string | null { const value = session.metadata?.[key]; @@ -579,33 +511,17 @@ async function findCodexSessionFileCached(session: Session): 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 calculateCost(data: CodexSessionData): CostEstimate | undefined { - const totalInputTokens = data.inputTokens + data.cachedTokens; - if (totalInputTokens === 0 && data.outputTokens === 0 && data.reasoningTokens === 0) { - return undefined; - } - - return { - inputTokens: totalInputTokens, - outputTokens: data.outputTokens, - estimatedCostUsd: - (data.inputTokens / 1_000_000) * 2.5 + - (data.cachedTokens / 1_000_000) * 0.625 + - ((data.outputTokens + data.reasoningTokens) / 1_000_000) * 10.0, - }; -} - function buildCodexSessionInfo(params: { agentSessionId: string | null; threadId: string | null; model: string | null; - cost?: CostEstimate; }): AgentSessionInfo { const metadata: Record = {}; if (params.threadId) metadata["codexThreadId"] = params.threadId; @@ -617,46 +533,19 @@ function buildCodexSessionInfo(params: { agentSessionId: params.agentSessionId, metadata: Object.keys(metadata).length > 0 ? metadata : undefined, }; - if (params.cost) info.cost = params.cost; return info; } -function buildMetadataOnlySessionInfo(session: Session): AgentSessionInfo | null { +function buildPersistedCodexSessionInfo(session: Session): AgentSessionInfo | null { const threadId = getSessionMetadataString(session, "codexThreadId"); - if (!threadId || !isTerminalSession(session)) return null; + const model = getSessionMetadataString(session, "codexModel"); + if (!threadId || (!model && !isTerminalSession(session))) return null; return buildCodexSessionInfo({ agentSessionId: threadId, threadId, - model: getSessionMetadataString(session, "codexModel"), - }); -} - -async function getCachedCodexSessionData(filePath: string): Promise { - const cached = sessionDataCache.get(filePath); - if (cached && Date.now() < cached.expiry) { - return cached.data; - } - - const inFlight = sessionDataInFlight.get(filePath); - if (inFlight) return inFlight; - - const promise = streamCodexSessionData(filePath).then((data) => { - if (data) { - sessionDataCache.set(filePath, { - data, - expiry: Date.now() + SESSION_DATA_CACHE_TTL_MS, - }); - } - return data; + model, }); - sessionDataInFlight.set(filePath, promise); - - try { - return await promise; - } finally { - sessionDataInFlight.delete(filePath); - } } /** @@ -912,20 +801,19 @@ function createCodexAgent(): Agent { }, async getSessionInfo(session: Session): Promise { - const metadataInfo = buildMetadataOnlySessionInfo(session); + const metadataInfo = buildPersistedCodexSessionInfo(session); if (metadataInfo) return metadataInfo; const sessionFile = await findCodexSessionFileCached(session); if (!sessionFile) return null; - const data = await getCachedCodexSessionData(sessionFile); + const data = await readCodexSessionMetadata(sessionFile); if (!data) return null; return buildCodexSessionInfo({ agentSessionId: data.threadId ?? basename(sessionFile, ".jsonl"), threadId: data.threadId, model: data.model, - cost: calculateCost(data), }); }, @@ -939,7 +827,7 @@ function createCodexAgent(): Agent { const sessionFile = await findCodexSessionFileCached(session); if (!sessionFile) return null; - const data = await getCachedCodexSessionData(sessionFile); + const data = await readCodexSessionMetadata(sessionFile); if (!data?.threadId) return null; threadId = data.threadId; model = data.model; @@ -998,8 +886,6 @@ export function create(): Agent { /** @internal Clear the session file cache. Exported for testing only. */ export function _resetSessionFileCache(): void { sessionFileCache.clear(); - sessionDataCache.clear(); - sessionDataInFlight.clear(); } export { CodexAppServerClient } from "./app-server-client.js"; 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 }; },