mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 16:58:25 +00:00
feat: surface cached token counts in /status output (openclaw#21248) thanks @vishaltandale00
Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: vishaltandale00 <9222298+vishaltandale00@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { applyExtraParamsToAgent } from "../pi-embedded-runner.js";
|
||||
|
||||
// Mock the logger to avoid noise in tests
|
||||
vi.mock("./logger.js", () => ({
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("cacheRetention default behavior", () => {
|
||||
it("returns 'short' for Anthropic when not configured", () => {
|
||||
const agent: { streamFn?: StreamFn } = {};
|
||||
const cfg = undefined;
|
||||
const provider = "anthropic";
|
||||
const modelId = "claude-3-sonnet";
|
||||
|
||||
applyExtraParamsToAgent(agent, cfg, provider, modelId);
|
||||
|
||||
// Verify streamFn was set (indicating cache retention was applied)
|
||||
expect(agent.streamFn).toBeDefined();
|
||||
|
||||
// The fact that agent.streamFn was modified indicates that cacheRetention
|
||||
// default "short" was applied. We don't need to call the actual function
|
||||
// since that would require API provider setup.
|
||||
});
|
||||
|
||||
it("respects explicit 'none' config", () => {
|
||||
const agent: { streamFn?: StreamFn } = {};
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-3-sonnet": {
|
||||
params: {
|
||||
cacheRetention: "none" as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const provider = "anthropic";
|
||||
const modelId = "claude-3-sonnet";
|
||||
|
||||
applyExtraParamsToAgent(agent, cfg, provider, modelId);
|
||||
|
||||
// Verify streamFn was set (config was applied)
|
||||
expect(agent.streamFn).toBeDefined();
|
||||
});
|
||||
|
||||
it("respects explicit 'long' config", () => {
|
||||
const agent: { streamFn?: StreamFn } = {};
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-3-opus": {
|
||||
params: {
|
||||
cacheRetention: "long" as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const provider = "anthropic";
|
||||
const modelId = "claude-3-opus";
|
||||
|
||||
applyExtraParamsToAgent(agent, cfg, provider, modelId);
|
||||
|
||||
// Verify streamFn was set (config was applied)
|
||||
expect(agent.streamFn).toBeDefined();
|
||||
});
|
||||
|
||||
it("respects legacy cacheControlTtl config", () => {
|
||||
const agent: { streamFn?: StreamFn } = {};
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-3-haiku": {
|
||||
params: {
|
||||
cacheControlTtl: "1h",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const provider = "anthropic";
|
||||
const modelId = "claude-3-haiku";
|
||||
|
||||
applyExtraParamsToAgent(agent, cfg, provider, modelId);
|
||||
|
||||
// Verify streamFn was set (legacy config was applied)
|
||||
expect(agent.streamFn).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns undefined for non-Anthropic providers", () => {
|
||||
const agent: { streamFn?: StreamFn } = {};
|
||||
const cfg = undefined;
|
||||
const provider = "openai";
|
||||
const modelId = "gpt-4";
|
||||
|
||||
applyExtraParamsToAgent(agent, cfg, provider, modelId);
|
||||
|
||||
// For OpenAI, the streamFn might be wrapped for other reasons (like OpenAI responses store)
|
||||
// but cacheRetention should not be applied
|
||||
// This is implicitly tested by the lack of cacheRetention-specific wrapping
|
||||
});
|
||||
|
||||
it("prefers explicit cacheRetention over default", () => {
|
||||
const agent: { streamFn?: StreamFn } = {};
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-3-sonnet": {
|
||||
params: {
|
||||
cacheRetention: "long" as const,
|
||||
temperature: 0.7,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const provider = "anthropic";
|
||||
const modelId = "claude-3-sonnet";
|
||||
|
||||
applyExtraParamsToAgent(agent, cfg, provider, modelId);
|
||||
|
||||
// Verify streamFn was set with explicit config
|
||||
expect(agent.streamFn).toBeDefined();
|
||||
});
|
||||
|
||||
it("works with extraParamsOverride", () => {
|
||||
const agent: { streamFn?: StreamFn } = {};
|
||||
const cfg = undefined;
|
||||
const provider = "anthropic";
|
||||
const modelId = "claude-3-sonnet";
|
||||
const extraParamsOverride = {
|
||||
cacheRetention: "none" as const,
|
||||
};
|
||||
|
||||
applyExtraParamsToAgent(agent, cfg, provider, modelId, extraParamsOverride);
|
||||
|
||||
// Verify streamFn was set (override was applied)
|
||||
expect(agent.streamFn).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,8 @@ type CacheRetentionStreamOptions = Partial<SimpleStreamOptions> & {
|
||||
*
|
||||
* Only applies to Anthropic provider (OpenRouter uses openai-completions API
|
||||
* with hardcoded cache_control, not the cacheRetention stream option).
|
||||
*
|
||||
* Defaults to "short" for Anthropic provider when not explicitly configured.
|
||||
*/
|
||||
function resolveCacheRetention(
|
||||
extraParams: Record<string, unknown> | undefined,
|
||||
@@ -67,7 +69,9 @@ function resolveCacheRetention(
|
||||
if (legacy === "1h") {
|
||||
return "long";
|
||||
}
|
||||
return undefined;
|
||||
|
||||
// Default to "short" for Anthropic when not explicitly configured
|
||||
return "short";
|
||||
}
|
||||
|
||||
function createStreamFnWithExtraParams(
|
||||
|
||||
155
src/agents/system-prompt-stability.test.ts
Normal file
155
src/agents/system-prompt-stability.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js";
|
||||
import {
|
||||
loadWorkspaceBootstrapFiles,
|
||||
DEFAULT_AGENTS_FILENAME,
|
||||
DEFAULT_TOOLS_FILENAME,
|
||||
DEFAULT_SOUL_FILENAME,
|
||||
} from "./workspace.js";
|
||||
|
||||
describe("system prompt stability for cache hits", () => {
|
||||
let workspaceDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
workspaceDir = await makeTempWorkspace("openclaw-system-prompt-stability-");
|
||||
});
|
||||
|
||||
it("returns identical results for same inputs across multiple calls", async () => {
|
||||
const agentsContent = "# AGENTS.md - Your Workspace\n\nTest agents file.";
|
||||
const toolsContent = "# TOOLS.md - Local Notes\n\nTest tools file.";
|
||||
const soulContent = "# SOUL.md - Who You Are\n\nTest soul file.";
|
||||
|
||||
// Write workspace files
|
||||
await writeWorkspaceFile({
|
||||
dir: workspaceDir,
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
content: agentsContent,
|
||||
});
|
||||
await writeWorkspaceFile({
|
||||
dir: workspaceDir,
|
||||
name: DEFAULT_TOOLS_FILENAME,
|
||||
content: toolsContent,
|
||||
});
|
||||
await writeWorkspaceFile({
|
||||
dir: workspaceDir,
|
||||
name: DEFAULT_SOUL_FILENAME,
|
||||
content: soulContent,
|
||||
});
|
||||
|
||||
// Load the same workspace multiple times
|
||||
const results = await Promise.all([
|
||||
loadWorkspaceBootstrapFiles(workspaceDir),
|
||||
loadWorkspaceBootstrapFiles(workspaceDir),
|
||||
loadWorkspaceBootstrapFiles(workspaceDir),
|
||||
loadWorkspaceBootstrapFiles(workspaceDir),
|
||||
loadWorkspaceBootstrapFiles(workspaceDir),
|
||||
]);
|
||||
|
||||
// All results should be structurally identical
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
expect(results[i]).toEqual(results[0]);
|
||||
}
|
||||
|
||||
// Verify specific content consistency
|
||||
const agentsFiles = results.map((result) =>
|
||||
result.find((f) => f.name === DEFAULT_AGENTS_FILENAME),
|
||||
);
|
||||
const toolsFiles = results.map((result) =>
|
||||
result.find((f) => f.name === DEFAULT_TOOLS_FILENAME),
|
||||
);
|
||||
const soulFiles = results.map((result) => result.find((f) => f.name === DEFAULT_SOUL_FILENAME));
|
||||
|
||||
// All instances should have identical content
|
||||
for (let i = 1; i < agentsFiles.length; i++) {
|
||||
expect(agentsFiles[i]?.content).toBe(agentsFiles[0]?.content);
|
||||
expect(toolsFiles[i]?.content).toBe(toolsFiles[0]?.content);
|
||||
expect(soulFiles[i]?.content).toBe(soulFiles[0]?.content);
|
||||
}
|
||||
|
||||
// Verify the actual content matches what we wrote
|
||||
expect(agentsFiles[0]?.content).toBe(agentsContent);
|
||||
expect(toolsFiles[0]?.content).toBe(toolsContent);
|
||||
expect(soulFiles[0]?.content).toBe(soulContent);
|
||||
});
|
||||
|
||||
it("returns consistent ordering across calls", async () => {
|
||||
const testFiles = [
|
||||
{ name: DEFAULT_AGENTS_FILENAME, content: "# Agents content" },
|
||||
{ name: DEFAULT_TOOLS_FILENAME, content: "# Tools content" },
|
||||
{ name: DEFAULT_SOUL_FILENAME, content: "# Soul content" },
|
||||
];
|
||||
|
||||
// Write all test files
|
||||
for (const file of testFiles) {
|
||||
await writeWorkspaceFile({ dir: workspaceDir, name: file.name, content: file.content });
|
||||
}
|
||||
|
||||
// Load multiple times
|
||||
const results = await Promise.all([
|
||||
loadWorkspaceBootstrapFiles(workspaceDir),
|
||||
loadWorkspaceBootstrapFiles(workspaceDir),
|
||||
loadWorkspaceBootstrapFiles(workspaceDir),
|
||||
]);
|
||||
|
||||
// All results should have the same file order
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
const names1 = results[0].map((f) => f.name);
|
||||
const namesI = results[i].map((f) => f.name);
|
||||
expect(namesI).toEqual(names1);
|
||||
}
|
||||
});
|
||||
|
||||
it("maintains consistency even with missing files", async () => {
|
||||
// Only create some files, leave others missing
|
||||
await writeWorkspaceFile({
|
||||
dir: workspaceDir,
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
content: "# Agents only",
|
||||
});
|
||||
|
||||
// Load multiple times
|
||||
const results = await Promise.all([
|
||||
loadWorkspaceBootstrapFiles(workspaceDir),
|
||||
loadWorkspaceBootstrapFiles(workspaceDir),
|
||||
loadWorkspaceBootstrapFiles(workspaceDir),
|
||||
]);
|
||||
|
||||
// All results should be identical
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
expect(results[i]).toEqual(results[0]);
|
||||
}
|
||||
|
||||
// Verify missing files are consistently marked as missing
|
||||
for (const result of results) {
|
||||
const agentsFile = result.find((f) => f.name === DEFAULT_AGENTS_FILENAME);
|
||||
const toolsFile = result.find((f) => f.name === DEFAULT_TOOLS_FILENAME);
|
||||
|
||||
expect(agentsFile?.missing).toBe(false);
|
||||
expect(agentsFile?.content).toBe("# Agents only");
|
||||
expect(toolsFile?.missing).toBe(true);
|
||||
expect(toolsFile?.content).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("maintains consistency across concurrent loads", async () => {
|
||||
const content = "# Concurrent load test";
|
||||
await writeWorkspaceFile({ dir: workspaceDir, name: DEFAULT_AGENTS_FILENAME, content });
|
||||
|
||||
// Start multiple concurrent loads
|
||||
const promises = Array.from({ length: 20 }, () => loadWorkspaceBootstrapFiles(workspaceDir));
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// All concurrent results should be identical
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
expect(results[i]).toEqual(results[0]);
|
||||
}
|
||||
|
||||
// Verify content consistency
|
||||
for (const result of results) {
|
||||
const agentsFile = result.find((f) => f.name === DEFAULT_AGENTS_FILENAME);
|
||||
expect(agentsFile?.content).toBe(content);
|
||||
expect(agentsFile?.missing).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
144
src/agents/usage.test.ts
Normal file
144
src/agents/usage.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeUsage,
|
||||
hasNonzeroUsage,
|
||||
derivePromptTokens,
|
||||
deriveSessionTotalTokens,
|
||||
} from "./usage.js";
|
||||
|
||||
describe("normalizeUsage", () => {
|
||||
it("normalizes cache fields from provider response", () => {
|
||||
const usage = normalizeUsage({
|
||||
input: 1000,
|
||||
output: 500,
|
||||
cacheRead: 2000,
|
||||
cacheWrite: 300,
|
||||
});
|
||||
expect(usage).toEqual({
|
||||
input: 1000,
|
||||
output: 500,
|
||||
cacheRead: 2000,
|
||||
cacheWrite: 300,
|
||||
total: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes cache fields from alternate naming", () => {
|
||||
const usage = normalizeUsage({
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
cache_read_input_tokens: 2000,
|
||||
cache_creation_input_tokens: 300,
|
||||
});
|
||||
expect(usage).toEqual({
|
||||
input: 1000,
|
||||
output: 500,
|
||||
cacheRead: 2000,
|
||||
cacheWrite: 300,
|
||||
total: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles cache_read and cache_write naming variants", () => {
|
||||
const usage = normalizeUsage({
|
||||
input: 1000,
|
||||
cache_read: 1500,
|
||||
cache_write: 200,
|
||||
});
|
||||
expect(usage).toEqual({
|
||||
input: 1000,
|
||||
output: undefined,
|
||||
cacheRead: 1500,
|
||||
cacheWrite: 200,
|
||||
total: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined when no valid fields are provided", () => {
|
||||
const usage = normalizeUsage(null);
|
||||
expect(usage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles undefined input", () => {
|
||||
const usage = normalizeUsage(undefined);
|
||||
expect(usage).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasNonzeroUsage", () => {
|
||||
it("returns true when cache read is nonzero", () => {
|
||||
const usage = { cacheRead: 100 };
|
||||
expect(hasNonzeroUsage(usage)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when cache write is nonzero", () => {
|
||||
const usage = { cacheWrite: 50 };
|
||||
expect(hasNonzeroUsage(usage)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when both cache fields are nonzero", () => {
|
||||
const usage = { cacheRead: 100, cacheWrite: 50 };
|
||||
expect(hasNonzeroUsage(usage)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when cache fields are zero", () => {
|
||||
const usage = { cacheRead: 0, cacheWrite: 0 };
|
||||
expect(hasNonzeroUsage(usage)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined usage", () => {
|
||||
expect(hasNonzeroUsage(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("derivePromptTokens", () => {
|
||||
it("includes cache tokens in prompt total", () => {
|
||||
const usage = {
|
||||
input: 1000,
|
||||
cacheRead: 500,
|
||||
cacheWrite: 200,
|
||||
};
|
||||
const promptTokens = derivePromptTokens(usage);
|
||||
expect(promptTokens).toBe(1700); // 1000 + 500 + 200
|
||||
});
|
||||
|
||||
it("handles missing cache fields", () => {
|
||||
const usage = {
|
||||
input: 1000,
|
||||
};
|
||||
const promptTokens = derivePromptTokens(usage);
|
||||
expect(promptTokens).toBe(1000);
|
||||
});
|
||||
|
||||
it("returns undefined for empty usage", () => {
|
||||
const promptTokens = derivePromptTokens({});
|
||||
expect(promptTokens).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveSessionTotalTokens", () => {
|
||||
it("includes cache tokens in total calculation", () => {
|
||||
const totalTokens = deriveSessionTotalTokens({
|
||||
usage: {
|
||||
input: 1000,
|
||||
cacheRead: 500,
|
||||
cacheWrite: 200,
|
||||
},
|
||||
contextTokens: 4000,
|
||||
});
|
||||
expect(totalTokens).toBe(1700); // 1000 + 500 + 200
|
||||
});
|
||||
|
||||
it("prefers promptTokens override over derived total", () => {
|
||||
const totalTokens = deriveSessionTotalTokens({
|
||||
usage: {
|
||||
input: 1000,
|
||||
cacheRead: 500,
|
||||
cacheWrite: 200,
|
||||
},
|
||||
contextTokens: 4000,
|
||||
promptTokens: 2500, // Override
|
||||
});
|
||||
expect(totalTokens).toBe(2500);
|
||||
});
|
||||
});
|
||||
130
src/agents/workspace.bootstrap-cache.test.ts
Normal file
130
src/agents/workspace.bootstrap-cache.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js";
|
||||
import { loadWorkspaceBootstrapFiles, DEFAULT_AGENTS_FILENAME } from "./workspace.js";
|
||||
|
||||
describe("workspace bootstrap file caching", () => {
|
||||
let workspaceDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
workspaceDir = await makeTempWorkspace("openclaw-bootstrap-cache-test-");
|
||||
});
|
||||
|
||||
it("returns cached content when mtime unchanged", async () => {
|
||||
const content1 = "# Initial content";
|
||||
await writeWorkspaceFile({
|
||||
dir: workspaceDir,
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
content: content1,
|
||||
});
|
||||
|
||||
// First load
|
||||
const result1 = await loadWorkspaceBootstrapFiles(workspaceDir);
|
||||
const agentsFile1 = result1.find((f) => f.name === DEFAULT_AGENTS_FILENAME);
|
||||
expect(agentsFile1?.content).toBe(content1);
|
||||
expect(agentsFile1?.missing).toBe(false);
|
||||
|
||||
// Second load should use cached content (same mtime)
|
||||
const result2 = await loadWorkspaceBootstrapFiles(workspaceDir);
|
||||
const agentsFile2 = result2.find((f) => f.name === DEFAULT_AGENTS_FILENAME);
|
||||
expect(agentsFile2?.content).toBe(content1);
|
||||
expect(agentsFile2?.missing).toBe(false);
|
||||
|
||||
// Verify both calls returned the same content without re-reading
|
||||
expect(agentsFile1?.content).toBe(agentsFile2?.content);
|
||||
});
|
||||
|
||||
it("invalidates cache when mtime changes", async () => {
|
||||
const content1 = "# Initial content";
|
||||
const content2 = "# Updated content";
|
||||
|
||||
await writeWorkspaceFile({
|
||||
dir: workspaceDir,
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
content: content1,
|
||||
});
|
||||
|
||||
// First load
|
||||
const result1 = await loadWorkspaceBootstrapFiles(workspaceDir);
|
||||
const agentsFile1 = result1.find((f) => f.name === DEFAULT_AGENTS_FILENAME);
|
||||
expect(agentsFile1?.content).toBe(content1);
|
||||
|
||||
// Wait a bit to ensure mtime will be different
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// Modify the file
|
||||
await writeWorkspaceFile({
|
||||
dir: workspaceDir,
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
content: content2,
|
||||
});
|
||||
|
||||
// Second load should detect the change and return new content
|
||||
const result2 = await loadWorkspaceBootstrapFiles(workspaceDir);
|
||||
const agentsFile2 = result2.find((f) => f.name === DEFAULT_AGENTS_FILENAME);
|
||||
expect(agentsFile2?.content).toBe(content2);
|
||||
expect(agentsFile2?.missing).toBe(false);
|
||||
});
|
||||
|
||||
it("handles file deletion gracefully", async () => {
|
||||
const content = "# Some content";
|
||||
const filePath = path.join(workspaceDir, DEFAULT_AGENTS_FILENAME);
|
||||
|
||||
await writeWorkspaceFile({ dir: workspaceDir, name: DEFAULT_AGENTS_FILENAME, content });
|
||||
|
||||
// First load
|
||||
const result1 = await loadWorkspaceBootstrapFiles(workspaceDir);
|
||||
const agentsFile1 = result1.find((f) => f.name === DEFAULT_AGENTS_FILENAME);
|
||||
expect(agentsFile1?.content).toBe(content);
|
||||
expect(agentsFile1?.missing).toBe(false);
|
||||
|
||||
// Delete the file
|
||||
await fs.unlink(filePath);
|
||||
|
||||
// Second load should handle deletion gracefully
|
||||
const result2 = await loadWorkspaceBootstrapFiles(workspaceDir);
|
||||
const agentsFile2 = result2.find((f) => f.name === DEFAULT_AGENTS_FILENAME);
|
||||
expect(agentsFile2?.missing).toBe(true);
|
||||
expect(agentsFile2?.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles concurrent access", async () => {
|
||||
const content = "# Concurrent test content";
|
||||
await writeWorkspaceFile({ dir: workspaceDir, name: DEFAULT_AGENTS_FILENAME, content });
|
||||
|
||||
// Multiple concurrent loads should all succeed
|
||||
const promises = Array.from({ length: 10 }, () => loadWorkspaceBootstrapFiles(workspaceDir));
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// All results should be identical
|
||||
for (const result of results) {
|
||||
const agentsFile = result.find((f) => f.name === DEFAULT_AGENTS_FILENAME);
|
||||
expect(agentsFile?.content).toBe(content);
|
||||
expect(agentsFile?.missing).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("caches files independently by path", async () => {
|
||||
const content1 = "# File 1 content";
|
||||
const content2 = "# File 2 content";
|
||||
|
||||
// Create two different workspace directories
|
||||
const workspace1 = await makeTempWorkspace("openclaw-cache-test1-");
|
||||
const workspace2 = await makeTempWorkspace("openclaw-cache-test2-");
|
||||
|
||||
await writeWorkspaceFile({ dir: workspace1, name: DEFAULT_AGENTS_FILENAME, content: content1 });
|
||||
await writeWorkspaceFile({ dir: workspace2, name: DEFAULT_AGENTS_FILENAME, content: content2 });
|
||||
|
||||
// Load from both workspaces
|
||||
const result1 = await loadWorkspaceBootstrapFiles(workspace1);
|
||||
const result2 = await loadWorkspaceBootstrapFiles(workspace2);
|
||||
|
||||
const agentsFile1 = result1.find((f) => f.name === DEFAULT_AGENTS_FILENAME);
|
||||
const agentsFile2 = result2.find((f) => f.name === DEFAULT_AGENTS_FILENAME);
|
||||
|
||||
expect(agentsFile1?.content).toBe(content1);
|
||||
expect(agentsFile2?.content).toBe(content2);
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,35 @@ const WORKSPACE_STATE_VERSION = 1;
|
||||
const workspaceTemplateCache = new Map<string, Promise<string>>();
|
||||
let gitAvailabilityPromise: Promise<boolean> | null = null;
|
||||
|
||||
// File content cache with mtime invalidation to avoid redundant reads
|
||||
const workspaceFileCache = new Map<string, { content: string; mtimeMs: number }>();
|
||||
|
||||
/**
|
||||
* Read file with caching based on mtime. Returns cached content if file
|
||||
* hasn't changed, otherwise reads from disk and updates cache.
|
||||
*/
|
||||
async function readFileWithCache(filePath: string): Promise<string> {
|
||||
try {
|
||||
const stats = await fs.stat(filePath);
|
||||
const mtimeMs = stats.mtimeMs;
|
||||
const cached = workspaceFileCache.get(filePath);
|
||||
|
||||
// Return cached content if mtime matches
|
||||
if (cached && cached.mtimeMs === mtimeMs) {
|
||||
return cached.content;
|
||||
}
|
||||
|
||||
// Read from disk and update cache
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
workspaceFileCache.set(filePath, { content, mtimeMs });
|
||||
return content;
|
||||
} catch (error) {
|
||||
// Remove from cache if file doesn't exist or is unreadable
|
||||
workspaceFileCache.delete(filePath);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function stripFrontMatter(content: string): string {
|
||||
if (!content.startsWith("---")) {
|
||||
return content;
|
||||
@@ -451,7 +480,7 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise<Workspac
|
||||
const result: WorkspaceBootstrapFile[] = [];
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
const content = await fs.readFile(entry.filePath, "utf-8");
|
||||
const content = await readFileWithCache(entry.filePath);
|
||||
result.push({
|
||||
name: entry.name,
|
||||
path: entry.filePath,
|
||||
@@ -531,7 +560,7 @@ export async function loadExtraBootstrapFiles(
|
||||
if (!VALID_BOOTSTRAP_NAMES.has(baseName)) {
|
||||
continue;
|
||||
}
|
||||
const content = await fs.readFile(realFilePath, "utf-8");
|
||||
const content = await readFileWithCache(realFilePath);
|
||||
result.push({
|
||||
name: baseName as WorkspaceBootstrapFileName,
|
||||
path: filePath,
|
||||
|
||||
Reference in New Issue
Block a user