Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions packages/scout-agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,7 @@ agent.on("chat", async ({ id, messages }) => {
}),
},
});
const stream = streamText(params);
return scout.processStreamTextOutput(stream);
return streamText(params);
});

agent.serve();
142 changes: 105 additions & 37 deletions packages/scout-agent/lib/compaction.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
/** biome-ignore-all lint/style/noNonNullAssertion: fine for tests */
/** biome-ignore-all lint/suspicious/noExplicitAny: fine for tests */
import { describe, expect, test } from "bun:test";
import { APICallError } from "ai";
import {
applyCompactionToMessages,
buildCompactionRequestMessage,
COMPACT_CONVERSATION_TOOL_NAME,
COMPACTION_MARKER_TOOL_NAME,
countCompactionMarkers,
maxConsecutiveCompactionAttempts,
createCompactionMarkerPart,
createCompactionTool,
findAPICallError,
findCompactionSummary,
isOutOfContextError,
MAX_CONSECUTIVE_COMPACTION_ATTEMPTS,
} from "./compaction";
import type { Message } from "./types";

Expand Down Expand Up @@ -55,62 +59,77 @@ function summaryMsg(
}

describe("isOutOfContextError", () => {
test("returns true for Anthropic context limit errors", () => {
expect(isOutOfContextError(new Error("max_tokens_exceeded"))).toBe(true);
const createApiError = (message: string) =>
new APICallError({
message,
url: "https://api.example.com",
requestBodyValues: {},
statusCode: 400,
});

test("returns true for APICallError with context limit message", () => {
expect(
isOutOfContextError(new Error("The context window has been exceeded"))
isOutOfContextError(
createApiError("Input is too long for requested model")
)
).toBe(true);
});

test("returns true for OpenAI context_length_exceeded errors", () => {
expect(isOutOfContextError(new Error("context_length_exceeded"))).toBe(
expect(isOutOfContextError(createApiError("context_length_exceeded"))).toBe(
true
);
});

test("returns true for generic token limit exceeded messages", () => {
expect(isOutOfContextError(new Error("token limit exceeded"))).toBe(true);
expect(
isOutOfContextError(new Error("Token limit has been exceeded"))
).toBe(true);
expect(isOutOfContextError(new Error("maximum tokens reached"))).toBe(true);
test("returns true for APICallError in cause chain", () => {
const apiError = createApiError("max_tokens_exceeded");
const wrapper = new Error("Gateway error");
(wrapper as { cause?: unknown }).cause = apiError;
expect(isOutOfContextError(wrapper)).toBe(true);
});

test("returns true for context window errors", () => {
expect(isOutOfContextError(new Error("context window exceeded"))).toBe(
true
);
expect(isOutOfContextError(new Error("context length exceeded"))).toBe(
true
test("returns false for APICallError with unrelated message", () => {
expect(isOutOfContextError(createApiError("authentication failed"))).toBe(
false
);
});

test("returns true for input too long errors", () => {
expect(isOutOfContextError(new Error("input is too long"))).toBe(true);
expect(isOutOfContextError(new Error("prompt is too long"))).toBe(true);
test("returns false for non-APICallError even if message matches pattern", () => {
expect(isOutOfContextError(new Error("context_length_exceeded"))).toBe(
false
);
expect(isOutOfContextError("input too long")).toBe(false);
});
});

describe("findAPICallError", () => {
const createApiError = (message: string) =>
new APICallError({
message,
url: "https://api.example.com",
requestBodyValues: {},
statusCode: 400,
});

test("returns false for unrelated errors", () => {
expect(isOutOfContextError(new Error("network error"))).toBe(false);
expect(isOutOfContextError(new Error("authentication failed"))).toBe(false);
expect(isOutOfContextError(new Error("rate limit exceeded"))).toBe(false);
test("returns the APICallError when provided directly", () => {
const error = createApiError("test");
expect(findAPICallError(error)).toBe(error);
});

test("handles string messages", () => {
expect(isOutOfContextError("token limit exceeded")).toBe(true);
expect(isOutOfContextError("some other error")).toBe(false);
test("returns APICallError from single-level cause", () => {
const apiError = createApiError("test");
const wrapper = new Error("wrapper");
(wrapper as { cause?: unknown }).cause = apiError;
expect(findAPICallError(wrapper)).toBe(apiError);
});

test("handles objects with message property", () => {
expect(isOutOfContextError({ message: "token limit exceeded" })).toBe(true);
expect(isOutOfContextError({ message: "some other error" })).toBe(false);
test("returns APICallError from deep cause chain", () => {
const apiError = createApiError("test");
const wrapper = { cause: { cause: apiError } };
expect(findAPICallError(wrapper)).toBe(apiError);
});

test("returns false for non-error values", () => {
expect(isOutOfContextError(null)).toBe(false);
expect(isOutOfContextError(undefined)).toBe(false);
expect(isOutOfContextError(123)).toBe(false);
expect(isOutOfContextError({})).toBe(false);
test("returns null when no APICallError present", () => {
expect(findAPICallError(new Error("other"))).toBeNull();
expect(findAPICallError("string")).toBeNull();
expect(findAPICallError(null)).toBeNull();
});
});

Expand Down Expand Up @@ -285,6 +304,37 @@ describe("countCompactionMarkers", () => {
});
});

describe("maxConsecutiveCompactionAttempts", () => {
test("counts consecutive assistant compaction attempts", () => {
const messages: Message[] = [
userMsg("1", "Hello"),
summaryMsg("summary-1", "Summary output 1"),
summaryMsg("summary-2", "Summary output 2"),
];

expect(maxConsecutiveCompactionAttempts(messages)).toBe(2);
});

test("does not count non-consecutive compaction attempts", () => {
const messages: Message[] = [
summaryMsg("summary-1", "First summary"),
userMsg("1", "Hello"),
summaryMsg("summary-2", "Second summary"),
];

expect(maxConsecutiveCompactionAttempts(messages)).toBe(1);
});

test("stops at non-compaction assistant message", () => {
const messages: Message[] = [
markerMsg("marker1"),
assistantMsg("assistant", "Normal reply"),
];

expect(maxConsecutiveCompactionAttempts(messages)).toBe(0);
});
});

describe("buildCompactionRequestMessage", () => {
test("creates user message with correct role", () => {
const message = buildCompactionRequestMessage();
Expand All @@ -311,6 +361,20 @@ describe("applyCompactionToMessages", () => {
expect(result).toEqual(messages);
});

test("throws when consecutive compaction attempts hit the limit", () => {
const attempts = MAX_CONSECUTIVE_COMPACTION_ATTEMPTS + 1;
const messages: Message[] = [
userMsg("1", "Hello"),
...Array.from({ length: attempts }, (_, idx) =>
summaryMsg(`summary-${idx}`, `Summary ${idx}`)
),
];

expect(() => applyCompactionToMessages(messages)).toThrow(
/Compaction loop detected/
);
});

test("excludes correct number of messages based on marker count", () => {
const messages: Message[] = [
userMsg("1", "Message 1"),
Expand Down Expand Up @@ -582,6 +646,10 @@ describe("applyCompactionToMessages", () => {
userMsg("3", "Third message"),
userMsg("4", "Fourth message"),
markerMsg("marker1"),
assistantMsg(
"assistant-buffer",
"Normal reply between compaction attempts"
),
markerMsg("marker2"),
userMsg("interrupted", "User interrupted compaction with this message"),
markerMsg("marker3"),
Expand Down
Loading