mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-08 06:02:54 +00:00
* refactor: extract agent core package Introduce packages/agent-core as the OpenClaw-owned home for reusable agent loop, harness, session, prompt, and runtime dependency contracts. * refactor: extract shared llm runtime Move provider model registries, stream wrappers, OAuth helpers, and LLM utilities into src/llm with plugin-sdk barrels instead of depending on the old embedded runtime layout. * refactor: remove pi runtime internals Rename remaining Pi-shaped agent surfaces to OpenClaw agent runtime names, delete obsolete Pi docs and package graph checks, and add the third-party notice for incorporated code. * refactor: tighten agent session runtime Make agent-core/runtime dependencies explicit, consolidate compaction and session transcript helpers, and move model/session helpers behind OpenClaw-owned contracts. * refactor: remove static model and pi auth paths Drop static model catalogs and Pi auth bridges, move model/provider facts to manifest-owned runtime contracts, and harden internal embedded-agent utilities. * refactor: remove legacy provider compat paths * docs: remove agent parity notes * fix: skip provider wildcard metadata parsing * refactor: share session extension sdk loading * refactor: inline acpx proxy error formatter * refactor: fold edit recovery into edit tool * fix: accept extension batch separator * test: align startup provider plugin expectations * fix: restore provider-scoped release discovery * test: align static asset packaging expectations * fix: run static provider catalogs during scoped discovery * fix: add provider entry catalogs for scoped live discovery * fix: load lightweight provider catalog entries * fix: refresh provider-scoped plugin metadata * fix: keep provider catalog entries on release live path * fix: keep static manifest models in release live checks * fix: harden release model discovery * fix: reduce OpenAI live cache probe reasoning * fix: disable OpenAI cache probe reasoning * ci: extend OpenAI gateway live timeout * fix: extend live gateway model budget * fix: stabilize release validation regressions * fix: honor provider aliases in model rows * fix: stabilize release validation lanes * fix: stabilize release memory qa * ci: stabilize release validation lanes * ci: prefer ipv4 for live docker node calls * fix: restore shared tool-call stream wrapper * ci: remove legacy pi test shard alias * fix: clean up embedded agent test drift * fix: stabilize runtime alias status * fix: clean up embedded agent ci drift * fix: restore release ci invariants * fix: clean up post-rebase runtime drift * fix: restore release ci checks * fix: restore release ci after rebase * fix: remove stale pi runtime path * test: align compaction runtime expectations * test: update plugin prerelease expectations * fix: handle claude live tool approvals * fix: stabilize release validation gates * fix: finish agent runtime import * test: finish post-rebase agent runtime mocks * fix: keep codex compaction native * fix: stabilize codex app-server hook tests * test: isolate codex diagnostic active run * test: remove codex diagnostic completion race # Conflicts: # extensions/codex/src/app-server/run-attempt.test.ts * ci: fix full release manifest performance run id * refactor: narrow llm plugin sdk boundary * chore: drop generated google boundary stamps * fix: repair rebase fallout * fix: clean up rebased runtime references * fix: decode codex jwt payloads as base64url * fix: preserve shipped pi runtime alias * fix: add scoped sdk virtual modules * fix: decode llm codex oauth jwt as base64url * fix: avoid stale vertex adc negative cache * fix: harden tool arg decoding and codeql path * fix: keep vertex adc negative checks live * refactor: consolidate codex jwt and edit helpers * fix: await codex oauth node runtime imports * fix: preserve sdk tool and notice contracts * fix: preserve shipped compat config boundaries * fix: align codex oauth callback host * fix: terminate agent-core loop streams on failure * fix: keep codex oauth callback alive during fallback * ci: include session tools in critical codeql scans * fix: keep Cloudflare Anthropic provider auth header * docs: redirect legacy pi runtime pages * fix: honor bundled web provider compat discovery * fix: protect session output spill files * fix: keep legacy agent dir env blocked * fix: contain auto-discovered skill symlinks * fix: harden agent core sdk proxy surfaces * fix: restore approval reaction sdk compat * fix: keep live docker runs bounded * fix: keep codex oauth redirect host aligned * fix: resolve post-rebase agent runtime drift * fix: redact anthropic oauth parse failures * fix: preserve responses strict tool shaping * fix: repair agent runtime rebase cleanup * docs: redirect retired parity pages * fix: bound auto-discovered resources to roots * fix: repair post-rebase agent test drift * fix: preserve bundled provider allowlist migration * fix: preserve manifest-owned provider aliases * fix: declare photon image dependency * fix: keep provider headers out of proxy body * fix: preserve shipped env aliases * fix: refresh control ui i18n generated state * fix: quote read fallback paths * fix: preview edits through configured backend * test: satisfy core test typecheck * fix: preserve ZAI usage auth fallback * test: repair codex diagnostic test * fix: repair agent runtime rebase drift * test: finish embedded runner import rename * fix: repair agent runtime rebase integrations * test: align compaction oauth fallback expectations * fix: allow sdk-auth session models * fix: update doctor tool schema import * fix: preserve bedrock plugin region * fix: stream harmony-like prose immediately * ci: include session runtime in codeql shards * fix: repair latest rebase integrations * fix: honor explicit codex websocket transport * fix: keep openai-compatible credentials provider-scoped * fix: refresh sdk api baseline after rebase * fix: route cli runtime aliases through openclaw harness * test: rename stale harness mock expectation * test: rename embedded agent overflow calls * test: clean embedded auth test wording * test: use openclaw stream types in deepinfra cache test * fix: refresh sdk api baseline on latest main * fix: honor bundled discovery compat allowlists * fix: refresh sdk api baseline after latest rebase * fix: remove stale rebase imports * test: rename stale model catalog mock * test: mock renamed doctor runtime modules * fix: map canonical kimi env auth * fix: use internal model registry in bench script * fix: migrate deepinfra provider catalog entry * fix: enforce builtin tool suppression * fix: route compaction auth and proxy payloads safely * refactor: prune unused llm registry leftovers * test: update codex hooks session import * test: fix model picker ci coverage * test: align model picker auth mock types
261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
import type { AgentMessage } from "./runtime/index.js";
|
|
import type { ExtensionContext } from "./sessions/index.js";
|
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const compactionMocks = vi.hoisted(() => {
|
|
function readText(value: unknown): string {
|
|
if (typeof value === "string") {
|
|
return value;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.map(readText).join("");
|
|
}
|
|
if (value && typeof value === "object") {
|
|
const record = value as { text?: unknown; content?: unknown; arguments?: unknown };
|
|
return `${readText(record.text)}${readText(record.content)}${readText(record.arguments)}`;
|
|
}
|
|
return "";
|
|
}
|
|
return {
|
|
estimateTokens: vi.fn((message: unknown) =>
|
|
Math.max(1, Math.ceil(readText(message).length / 4)),
|
|
),
|
|
generateSummary: vi.fn(),
|
|
logWarn: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock("./sessions/index.js", async () => {
|
|
const actual = await vi.importActual<typeof import("./sessions/index.js")>("./sessions/index.js");
|
|
return {
|
|
...actual,
|
|
estimateTokens: compactionMocks.estimateTokens,
|
|
generateSummary: compactionMocks.generateSummary,
|
|
};
|
|
});
|
|
|
|
vi.mock("../logging/subsystem.js", () => ({
|
|
createSubsystemLogger: () => ({
|
|
info: vi.fn(),
|
|
warn: compactionMocks.logWarn,
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
trace: vi.fn(),
|
|
raw: vi.fn(),
|
|
child: vi.fn().mockReturnThis(),
|
|
}),
|
|
}));
|
|
|
|
// Mock retryAsync to bypass retry delays while preserving the single-call semantic.
|
|
// summarizeChunks wraps generateSummary in retryAsync with 500-5000 ms delays;
|
|
// eliminating them keeps tests fast without altering the catch-block behavior under test.
|
|
vi.mock("../infra/retry.js", async () => {
|
|
const actual = await vi.importActual<typeof import("../infra/retry.js")>("../infra/retry.js");
|
|
return {
|
|
...actual,
|
|
retryAsync: async <T>(fn: () => Promise<T>) => fn(),
|
|
};
|
|
});
|
|
|
|
let summarizeWithFallback: typeof import("./compaction.js").summarizeWithFallback;
|
|
|
|
beforeAll(async () => {
|
|
vi.resetModules();
|
|
({ summarizeWithFallback } = await import("./compaction.js"));
|
|
});
|
|
|
|
describe("summarizeChunks partial summary preservation (#82952)", () => {
|
|
const testModel = {
|
|
id: "test",
|
|
name: "test",
|
|
contextWindow: 200_000,
|
|
contextTokens: 200_000,
|
|
maxTokens: 8192,
|
|
} as unknown as NonNullable<ExtensionContext["model"]>;
|
|
|
|
// Two messages sized to split into two chunks with maxChunkTokens=150.
|
|
// Each message is ~100 tokens (400 chars / 4), and effectiveMax = floor(150/1.2) = 125.
|
|
const twoChunkMessages: AgentMessage[] = [
|
|
{ role: "user", content: "x".repeat(400), timestamp: 1 },
|
|
{ role: "user", content: "y".repeat(400), timestamp: 2 },
|
|
];
|
|
|
|
function callSummarize(messages = twoChunkMessages) {
|
|
return summarizeWithFallback({
|
|
messages,
|
|
model: testModel,
|
|
apiKey: "test-key", // pragma: allowlist secret
|
|
signal: new AbortController().signal,
|
|
reserveTokens: 1000,
|
|
maxChunkTokens: 150,
|
|
contextWindow: 200_000,
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
compactionMocks.generateSummary.mockReset();
|
|
compactionMocks.logWarn.mockClear();
|
|
});
|
|
|
|
it("returns partial summary when a later chunk fails with a non-abort error", async () => {
|
|
compactionMocks.generateSummary
|
|
.mockResolvedValueOnce("Summary of chunk 1")
|
|
.mockRejectedValue(new Error("API quota exceeded"));
|
|
|
|
const result = await callSummarize();
|
|
|
|
expect(result).toContain("Summary of chunk 1");
|
|
expect(result).toContain("[Partial summary:");
|
|
expect(result).toMatch(/chunks 1-1 of 2 were summarized/);
|
|
expect(compactionMocks.logWarn).toHaveBeenCalledWith(
|
|
"chunk summarization failed after retries; partial summary available",
|
|
expect.objectContaining({ err: expect.any(Error) }),
|
|
);
|
|
});
|
|
|
|
it("re-throws abort errors instead of returning partial summary", async () => {
|
|
const abortErr = new Error("aborted");
|
|
abortErr.name = "AbortError";
|
|
|
|
compactionMocks.generateSummary
|
|
.mockResolvedValueOnce("Summary of chunk 1")
|
|
.mockRejectedValue(abortErr);
|
|
|
|
const result = await callSummarize();
|
|
|
|
// Abort error propagates from summarizeChunks; summarizeWithFallback catches it
|
|
// and falls through to the final fallback (not the partial summary).
|
|
expect(result).not.toBe("Summary of chunk 1");
|
|
expect(result).toContain("Context contained");
|
|
expect(compactionMocks.logWarn).not.toHaveBeenCalledWith(
|
|
"chunk summarization failed after retries; partial summary available",
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("re-throws timeout errors instead of returning partial summary", async () => {
|
|
const timeoutErr = new Error("request timed out");
|
|
timeoutErr.name = "TimeoutError";
|
|
|
|
compactionMocks.generateSummary
|
|
.mockResolvedValueOnce("Summary of chunk 1")
|
|
.mockRejectedValue(timeoutErr);
|
|
|
|
const result = await callSummarize();
|
|
|
|
expect(result).not.toBe("Summary of chunk 1");
|
|
expect(result).toContain("Context contained");
|
|
expect(compactionMocks.logWarn).not.toHaveBeenCalledWith(
|
|
"chunk summarization failed after retries; partial summary available",
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("returns the full final summary when all chunks succeed", async () => {
|
|
compactionMocks.generateSummary
|
|
.mockResolvedValueOnce("Summary of chunk 1")
|
|
.mockResolvedValueOnce("Combined summary of chunks 1+2");
|
|
|
|
const result = await callSummarize();
|
|
|
|
expect(result).toBe("Combined summary of chunks 1+2");
|
|
expect(compactionMocks.generateSummary).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("falls back to default when the first chunk fails (no partial to recover)", async () => {
|
|
compactionMocks.generateSummary.mockRejectedValue(new Error("network error"));
|
|
|
|
const result = await callSummarize();
|
|
|
|
// With no successful chunk, summarizeChunks rethrows into
|
|
// summarizeWithFallback's outer catch -> final fallback path.
|
|
expect(result).toContain("Context contained");
|
|
expect(result).not.toBe("Summary of chunk 1");
|
|
});
|
|
|
|
it("tries oversized-message retry before falling back to partial summary", async () => {
|
|
// Scenario: chunk 1 (small) succeeds, chunk 2 (has oversized message) fails.
|
|
// summarizeWithFallback should try the non-oversized retry, which may
|
|
// recover more content than the partial summary alone.
|
|
const mixedMessages: AgentMessage[] = [
|
|
// Small message (chunk 1)
|
|
{ role: "user", content: "Short question about code", timestamp: 1 },
|
|
// Oversized message (will be in chunk 2, triggers the oversized retry)
|
|
{ role: "assistant", content: "x".repeat(500_000), timestamp: 2 } as unknown as AgentMessage,
|
|
// Small message after oversized (should be recovered by oversized retry)
|
|
{ role: "user", content: "Follow-up question", timestamp: 3 },
|
|
];
|
|
|
|
compactionMocks.generateSummary
|
|
// Call 1: chunk 1 of full attempt (success)
|
|
.mockResolvedValueOnce("Summary of chunk 1")
|
|
// Call 2: chunk 2 of full attempt (fails - oversized message)
|
|
.mockRejectedValueOnce(new Error("context too long"))
|
|
// Call 3: oversized retry with small messages only (succeeds!)
|
|
.mockResolvedValueOnce("Summary of small messages (oversized retry)");
|
|
|
|
const result = await callSummarize(mixedMessages);
|
|
|
|
// The oversized retry should have recovered more content than
|
|
// the partial summary from chunk 1 alone.
|
|
expect(result).toContain("Summary of small messages (oversized retry)");
|
|
// The partial summary should NOT be the final result because the
|
|
// oversized retry succeeded.
|
|
expect(result).not.toContain("[Partial summary:");
|
|
});
|
|
|
|
it("prefers oversized retry partial summary over full attempt partial", async () => {
|
|
// Scenario: full attempt's chunk 1 succeeds, chunk 2 (oversized) fails.
|
|
// Oversized retry (small messages only) chunk 1 succeeds, chunk 2 fails.
|
|
// The oversized retry's partial summary should be preferred because it
|
|
// covers the non-oversized transcript.
|
|
const mixedMessages: AgentMessage[] = [
|
|
{ role: "user", content: "Short question", timestamp: 1 },
|
|
// Oversized message that will be filtered in the retry
|
|
{ role: "assistant", content: "x".repeat(500_000), timestamp: 2 } as unknown as AgentMessage,
|
|
{ role: "user", content: "a".repeat(400), timestamp: 3 },
|
|
{ role: "user", content: "b".repeat(400), timestamp: 4 },
|
|
];
|
|
|
|
compactionMocks.generateSummary
|
|
// Full attempt: chunk 1 succeeds, chunk 2 fails (oversized message)
|
|
.mockResolvedValueOnce("Full attempt chunk 1")
|
|
.mockRejectedValueOnce(new Error("context too long"))
|
|
// Oversized retry: chunk 1 succeeds, chunk 2 also fails
|
|
.mockResolvedValueOnce("Oversized retry chunk 1 (better coverage)")
|
|
.mockRejectedValue(new Error("rate limited on retry"));
|
|
|
|
const result = await callSummarize(mixedMessages);
|
|
|
|
// The oversized retry's partial summary should win, with oversized notes
|
|
expect(result).toContain("Oversized retry chunk 1 (better coverage)");
|
|
expect(result).toContain("[Partial summary:");
|
|
expect(result).toContain("[Large assistant");
|
|
expect(result).toContain("omitted from summary]");
|
|
});
|
|
|
|
it("preserves the latest successful summary in a 3+ chunk chain", async () => {
|
|
const threeChunkMessages: AgentMessage[] = [
|
|
{ role: "user", content: "a".repeat(400), timestamp: 1 },
|
|
{ role: "user", content: "b".repeat(400), timestamp: 2 },
|
|
{ role: "user", content: "c".repeat(400), timestamp: 3 },
|
|
];
|
|
|
|
compactionMocks.generateSummary
|
|
.mockResolvedValueOnce("Summary after chunk 1")
|
|
.mockResolvedValueOnce("Summary after chunks 1+2")
|
|
.mockRejectedValue(new Error("rate limited"));
|
|
|
|
const result = await callSummarize(threeChunkMessages);
|
|
|
|
// Chunk 3 failed -> partial summary from chunk 2 is returned with marker.
|
|
expect(result).toContain("Summary after chunks 1+2");
|
|
expect(result).toMatch(/\[Partial summary: chunks 1-2 of 3 were summarized/);
|
|
expect(compactionMocks.generateSummary).toHaveBeenCalledTimes(3);
|
|
expect(compactionMocks.logWarn).toHaveBeenCalledWith(
|
|
"chunk summarization failed after retries; partial summary available",
|
|
expect.objectContaining({ completedChunks: 2, totalChunks: 3 }),
|
|
);
|
|
});
|
|
});
|