mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
fix: simplify excluded custom message tests
This commit is contained in:
@@ -560,10 +560,16 @@ describe("Agent", () => {
|
||||
await firstPrompt.catch(() => {});
|
||||
});
|
||||
|
||||
it("continue() should reserve the active run while awaiting async LLM conversion", async () => {
|
||||
it("continue() should reserve the active run before async LLM conversion and use the filtered context tail", async () => {
|
||||
const convertStarted = createDeferred();
|
||||
const releaseConvert = createDeferred();
|
||||
let convertCallCount = 0;
|
||||
let providerMessages: Message[] = [];
|
||||
const displayOnlyMessage = {
|
||||
role: "displayOnly",
|
||||
content: "status",
|
||||
timestamp: Date.now(),
|
||||
} as unknown as AgentMessage;
|
||||
const agent = new Agent({
|
||||
convertToLlm: async (messages) => {
|
||||
convertCallCount++;
|
||||
@@ -571,11 +577,14 @@ describe("Agent", () => {
|
||||
convertStarted.resolve();
|
||||
await releaseConvert.promise;
|
||||
}
|
||||
return messages.filter(
|
||||
(message) => message.role === "user" || message.role === "assistant" || message.role === "toolResult",
|
||||
) as Message[];
|
||||
return messages
|
||||
.filter((message) => (message as { role: string }).role !== "displayOnly")
|
||||
.filter(
|
||||
(message) => message.role === "user" || message.role === "assistant" || message.role === "toolResult",
|
||||
) as Message[];
|
||||
},
|
||||
streamFn: () => {
|
||||
streamFn: (_model, context) => {
|
||||
providerMessages = context.messages;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Processed") });
|
||||
@@ -587,9 +596,16 @@ describe("Agent", () => {
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Initial" }],
|
||||
timestamp: Date.now(),
|
||||
timestamp: Date.now() - 10,
|
||||
},
|
||||
createAssistantMessage("Initial response"),
|
||||
displayOnlyMessage,
|
||||
];
|
||||
agent.followUp({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Queued follow-up" }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const continuePromise = agent.continue();
|
||||
await convertStarted.promise;
|
||||
@@ -601,6 +617,8 @@ describe("Agent", () => {
|
||||
await continuePromise;
|
||||
|
||||
expect(convertCallCount).toBe(2);
|
||||
expect(providerMessages[providerMessages.length - 1]?.role).toBe("user");
|
||||
expect(agent.state.messages).toContain(displayOnlyMessage);
|
||||
});
|
||||
|
||||
it("continue() should process queued follow-up messages after an assistant turn", async () => {
|
||||
@@ -641,54 +659,6 @@ describe("Agent", () => {
|
||||
expect(agent.state.messages[agent.state.messages.length - 1].role).toBe("assistant");
|
||||
});
|
||||
|
||||
it("continue() should process queued follow-up messages after filtered state messages", async () => {
|
||||
let providerCallCount = 0;
|
||||
let providerMessages: Message[] = [];
|
||||
const displayOnlyMessage = {
|
||||
role: "displayOnly",
|
||||
content: "status",
|
||||
timestamp: Date.now(),
|
||||
} as unknown as AgentMessage;
|
||||
const agent = new Agent({
|
||||
convertToLlm: (messages) =>
|
||||
messages
|
||||
.filter((message) => (message as { role: string }).role !== "displayOnly")
|
||||
.filter(
|
||||
(message) => message.role === "user" || message.role === "assistant" || message.role === "toolResult",
|
||||
) as Message[],
|
||||
streamFn: (_model, context) => {
|
||||
providerCallCount++;
|
||||
providerMessages = context.messages;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Processed") });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
agent.state.messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Initial" }],
|
||||
timestamp: Date.now() - 10,
|
||||
},
|
||||
createAssistantMessage("Initial response"),
|
||||
displayOnlyMessage,
|
||||
];
|
||||
agent.followUp({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Queued follow-up" }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
await agent.continue();
|
||||
|
||||
expect(providerCallCount).toBe(1);
|
||||
expect(providerMessages[providerMessages.length - 1]?.role).toBe("user");
|
||||
expect(agent.state.messages).toContain(displayOnlyMessage);
|
||||
});
|
||||
|
||||
it("continue() should keep one-at-a-time steering semantics from assistant tail", async () => {
|
||||
let responseCount = 0;
|
||||
const agent = new Agent({
|
||||
|
||||
@@ -234,42 +234,6 @@ describe("harness compaction", () => {
|
||||
isSplitTurn: false,
|
||||
});
|
||||
|
||||
const cutUser = createMessageEntry(createUserMessage("inspect file"));
|
||||
const cutAssistant = createMessageEntry(
|
||||
{
|
||||
...createAssistantMessage("calling tool"),
|
||||
content: [{ type: "toolCall", id: "call-2", name: "read", arguments: { path: "file.ts" } }],
|
||||
},
|
||||
cutUser.id,
|
||||
);
|
||||
const cutToolResult = createMessageEntry(
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-2",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "x".repeat(1000) }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
cutAssistant.id,
|
||||
);
|
||||
const excludedCustom: CustomMessageEntry = {
|
||||
type: "custom_message",
|
||||
id: createId(),
|
||||
parentId: cutToolResult.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
customType: "status",
|
||||
content: "tool finished",
|
||||
display: true,
|
||||
excludeFromContext: true,
|
||||
};
|
||||
const cutAssistantFinal = createMessageEntry(createAssistantMessage("done"), excludedCustom.id);
|
||||
expect(findCutPoint([cutUser, cutAssistant, cutToolResult, excludedCustom, cutAssistantFinal], 0, 5, 2)).toEqual({
|
||||
firstKeptEntryIndex: 4,
|
||||
turnStartIndex: 0,
|
||||
isSplitTurn: true,
|
||||
});
|
||||
|
||||
const user = createMessageEntry(createUserMessage("user"));
|
||||
const compaction = createCompactionEntry("summary", user.id, user.id);
|
||||
const assistant = createMessageEntry(createAssistantMessage("assistant"), compaction.id);
|
||||
@@ -425,8 +389,8 @@ describe("harness compaction", () => {
|
||||
expect([...preparation!.fileOps.written]).toContain("written.ts");
|
||||
});
|
||||
|
||||
it("skips excluded custom messages during compaction token estimates", () => {
|
||||
const customMessage: CustomMessageEntry = {
|
||||
it("ignores excluded custom messages during compaction and branch-summary preparation", () => {
|
||||
const excludedCustom: CustomMessageEntry = {
|
||||
type: "custom_message",
|
||||
id: createId(),
|
||||
parentId: null,
|
||||
@@ -436,26 +400,27 @@ describe("harness compaction", () => {
|
||||
display: true,
|
||||
excludeFromContext: true,
|
||||
};
|
||||
const user = createMessageEntry(createUserMessage("keep"), customMessage.id);
|
||||
const user = createMessageEntry(createUserMessage("keep"), excludedCustom.id);
|
||||
|
||||
const preparation = getOrThrow(
|
||||
prepareCompaction([customMessage, user], { enabled: true, reserveTokens: 0, keepRecentTokens: 1 }),
|
||||
const simplePreparation = getOrThrow(
|
||||
prepareCompaction([excludedCustom, user], { enabled: true, reserveTokens: 0, keepRecentTokens: 1 }),
|
||||
);
|
||||
const branchPreparation = prepareBranchEntries([excludedCustom, user], 10);
|
||||
|
||||
expect(preparation?.tokensBefore).toBe(1);
|
||||
expect(preparation?.messagesToSummarize).toEqual([]);
|
||||
});
|
||||
expect(simplePreparation?.tokensBefore).toBe(1);
|
||||
expect(simplePreparation?.messagesToSummarize).toEqual([]);
|
||||
expect(branchPreparation.messages.map((message) => message.role)).toEqual(["user"]);
|
||||
expect(branchPreparation.totalTokens).toBe(1);
|
||||
|
||||
it("ignores excluded custom messages when finding split-turn prefixes", () => {
|
||||
const user = createMessageEntry(createUserMessage("inspect file"));
|
||||
const splitUser = createMessageEntry(createUserMessage("inspect file"));
|
||||
const assistantWithToolCall = createMessageEntry(
|
||||
{
|
||||
...createAssistantMessage("calling tool"),
|
||||
content: [{ type: "toolCall", id: "call-1", name: "read", arguments: { path: "file.ts" } }],
|
||||
},
|
||||
user.id,
|
||||
splitUser.id,
|
||||
);
|
||||
const customMessage: CustomMessageEntry = {
|
||||
const splitExcludedCustom: CustomMessageEntry = {
|
||||
type: "custom_message",
|
||||
id: createId(),
|
||||
parentId: assistantWithToolCall.id,
|
||||
@@ -474,47 +439,26 @@ describe("harness compaction", () => {
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
customMessage.id,
|
||||
splitExcludedCustom.id,
|
||||
);
|
||||
const assistantFinal = createMessageEntry(createAssistantMessage("done"), toolResult.id);
|
||||
|
||||
const preparation = getOrThrow(
|
||||
prepareCompaction([user, assistantWithToolCall, customMessage, toolResult, assistantFinal], {
|
||||
const splitPreparation = getOrThrow(
|
||||
prepareCompaction([splitUser, assistantWithToolCall, splitExcludedCustom, toolResult, assistantFinal], {
|
||||
enabled: true,
|
||||
reserveTokens: 0,
|
||||
keepRecentTokens: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(preparation).toBeDefined();
|
||||
expect(preparation?.isSplitTurn).toBe(true);
|
||||
expect(preparation?.firstKeptEntryId).toBe(assistantFinal.id);
|
||||
expect(preparation?.turnPrefixMessages.map((message) => message.role)).toEqual([
|
||||
expect(splitPreparation?.isSplitTurn).toBe(true);
|
||||
expect(splitPreparation?.firstKeptEntryId).toBe(assistantFinal.id);
|
||||
expect(splitPreparation?.turnPrefixMessages.map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips excluded custom messages before branch summary token budgeting", () => {
|
||||
const user = createMessageEntry(createUserMessage("keep"));
|
||||
const customMessage: CustomMessageEntry = {
|
||||
type: "custom_message",
|
||||
id: createId(),
|
||||
parentId: user.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
customType: "status",
|
||||
content: "x".repeat(1000),
|
||||
display: true,
|
||||
excludeFromContext: true,
|
||||
};
|
||||
|
||||
const preparation = prepareBranchEntries([user, customMessage], 10);
|
||||
|
||||
expect(preparation.messages.map((message) => message.role)).toEqual(["user"]);
|
||||
expect(preparation.totalTokens).toBe(1);
|
||||
});
|
||||
|
||||
it("prepares custom and branch summary entries for summarization", () => {
|
||||
const branchSummary: BranchSummaryEntry = {
|
||||
type: "branch_summary",
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { prepareBranchEntries } from "../src/core/compaction/branch-summarization.ts";
|
||||
import type { CustomMessageEntry, SessionMessageEntry } from "../src/core/session-manager.ts";
|
||||
|
||||
function userEntry(id: string, parentId: string | null, text: string): SessionMessageEntry {
|
||||
return {
|
||||
type: "message",
|
||||
id,
|
||||
parentId,
|
||||
timestamp: "2025-01-01T00:00:00Z",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text }],
|
||||
timestamp: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function customEntry(
|
||||
id: string,
|
||||
parentId: string | null,
|
||||
content: string,
|
||||
excludeFromContext: boolean,
|
||||
): CustomMessageEntry {
|
||||
return {
|
||||
type: "custom_message",
|
||||
id,
|
||||
parentId,
|
||||
timestamp: "2025-01-01T00:00:00Z",
|
||||
customType: "status",
|
||||
content,
|
||||
display: true,
|
||||
excludeFromContext,
|
||||
};
|
||||
}
|
||||
|
||||
describe("branch summarization", () => {
|
||||
it("skips excluded custom messages before token budgeting", () => {
|
||||
const user = userEntry("user", null, "keep");
|
||||
const excluded = customEntry("custom", user.id, "x".repeat(1000), true);
|
||||
|
||||
const preparation = prepareBranchEntries([user, excluded], 10);
|
||||
|
||||
expect(preparation.messages.map((message) => message.role)).toEqual(["user"]);
|
||||
expect(preparation.totalTokens).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { getModel } from "@earendil-works/pi-ai";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { prepareBranchEntries } from "../src/core/compaction/branch-summarization.ts";
|
||||
import {
|
||||
type CompactionSettings,
|
||||
calculateContextTokens,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
prepareCompaction,
|
||||
shouldCompact,
|
||||
} from "../src/core/compaction/index.ts";
|
||||
import type { CustomMessage } from "../src/core/messages.ts";
|
||||
import {
|
||||
buildSessionContext,
|
||||
type CompactionEntry,
|
||||
@@ -202,27 +202,6 @@ describe("Token calculation", () => {
|
||||
const usage = createMockUsage(0, 0, 0, 0);
|
||||
expect(calculateContextTokens(usage)).toBe(0);
|
||||
});
|
||||
|
||||
it("should ignore excluded custom messages in context token estimates", () => {
|
||||
const excludedCustom: CustomMessage = {
|
||||
role: "custom",
|
||||
customType: "status",
|
||||
content: "x".repeat(1000),
|
||||
display: true,
|
||||
excludeFromContext: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const visibleCustom: CustomMessage = { ...excludedCustom, excludeFromContext: false };
|
||||
const assistant = createAssistantMessage("assistant", createMockUsage(10, 5));
|
||||
|
||||
expect(estimateContextTokens([excludedCustom])).toMatchObject({ tokens: 0, trailingTokens: 0 });
|
||||
expect(estimateContextTokens([visibleCustom]).tokens).toBeGreaterThan(0);
|
||||
expect(estimateContextTokens([assistant, excludedCustom])).toMatchObject({
|
||||
tokens: 15,
|
||||
usageTokens: 15,
|
||||
trailingTokens: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLastAssistantUsage", () => {
|
||||
@@ -346,31 +325,6 @@ describe("findCutPoint", () => {
|
||||
expect(result.turnStartIndex).toBe(2); // Turn 2 starts at index 2
|
||||
}
|
||||
});
|
||||
|
||||
it("should not select excluded custom messages as cut points", () => {
|
||||
const user = createMessageEntry(createUserMessage("inspect file"));
|
||||
const assistantWithToolCall = createMessageEntry({
|
||||
...createAssistantMessage("calling tool"),
|
||||
content: [{ type: "toolCall", id: "call-1", name: "read", arguments: { path: "file.ts" } }],
|
||||
});
|
||||
const toolResult = createMessageEntry({
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "x".repeat(1000) }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const excludedCustom = createCustomMessageEntry("tool finished", true);
|
||||
const assistantFinal = createMessageEntry(createAssistantMessage("done"));
|
||||
const entries = [user, assistantWithToolCall, toolResult, excludedCustom, assistantFinal];
|
||||
|
||||
const result = findCutPoint(entries, 0, entries.length, 2);
|
||||
|
||||
expect(result.firstKeptEntryIndex).toBe(4);
|
||||
expect(result.turnStartIndex).toBe(0);
|
||||
expect(result.isSplitTurn).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSessionContext", () => {
|
||||
@@ -460,27 +414,29 @@ describe("buildSessionContext", () => {
|
||||
});
|
||||
|
||||
describe("prepareCompaction with custom messages", () => {
|
||||
it("should ignore excluded custom messages in token estimates and summarized messages", () => {
|
||||
it("should ignore excluded custom messages in compaction and branch-summary preparation", () => {
|
||||
const excludedCustom = createCustomMessageEntry("x".repeat(1000), true);
|
||||
const user = createMessageEntry(createUserMessage("keep"));
|
||||
const preparation = prepareCompaction([excludedCustom, user], {
|
||||
const simplePreparation = prepareCompaction([excludedCustom, user], {
|
||||
enabled: true,
|
||||
reserveTokens: 0,
|
||||
keepRecentTokens: 1,
|
||||
});
|
||||
const branchPreparation = prepareBranchEntries([excludedCustom, user], 10);
|
||||
|
||||
expect(preparation).toBeDefined();
|
||||
expect(preparation!.tokensBefore).toBe(1);
|
||||
expect(preparation!.messagesToSummarize).toEqual([]);
|
||||
});
|
||||
expect(simplePreparation).toBeDefined();
|
||||
expect(simplePreparation!.tokensBefore).toBe(1);
|
||||
expect(simplePreparation!.messagesToSummarize).toEqual([]);
|
||||
expect(branchPreparation.messages.map((message) => message.role)).toEqual(["user"]);
|
||||
expect(branchPreparation.totalTokens).toBe(1);
|
||||
|
||||
it("should ignore excluded custom messages when finding split-turn prefixes", () => {
|
||||
const user = createMessageEntry(createUserMessage("inspect file"));
|
||||
resetEntryCounter();
|
||||
const splitUser = createMessageEntry(createUserMessage("inspect file"));
|
||||
const assistantWithToolCall = createMessageEntry({
|
||||
...createAssistantMessage("calling tool"),
|
||||
content: [{ type: "toolCall", id: "call-1", name: "read", arguments: { path: "file.ts" } }],
|
||||
});
|
||||
const excludedCustom = createCustomMessageEntry("tool is running", true);
|
||||
const splitExcludedCustom = createCustomMessageEntry("tool is running", true);
|
||||
const toolResult = createMessageEntry({
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
@@ -490,17 +446,19 @@ describe("prepareCompaction with custom messages", () => {
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const assistantFinal = createMessageEntry(createAssistantMessage("done"));
|
||||
const splitPreparation = prepareCompaction(
|
||||
[splitUser, assistantWithToolCall, splitExcludedCustom, toolResult, assistantFinal],
|
||||
{
|
||||
enabled: true,
|
||||
reserveTokens: 0,
|
||||
keepRecentTokens: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const preparation = prepareCompaction([user, assistantWithToolCall, excludedCustom, toolResult, assistantFinal], {
|
||||
enabled: true,
|
||||
reserveTokens: 0,
|
||||
keepRecentTokens: 1,
|
||||
});
|
||||
|
||||
expect(preparation).toBeDefined();
|
||||
expect(preparation!.isSplitTurn).toBe(true);
|
||||
expect(preparation!.firstKeptEntryId).toBe(assistantFinal.id);
|
||||
expect(preparation!.turnPrefixMessages.map((message) => message.role)).toEqual([
|
||||
expect(splitPreparation).toBeDefined();
|
||||
expect(splitPreparation!.isSplitTurn).toBe(true);
|
||||
expect(splitPreparation!.firstKeptEntryId).toBe(assistantFinal.id);
|
||||
expect(splitPreparation!.turnPrefixMessages.map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
|
||||
@@ -145,41 +145,17 @@ describe("AgentSession bash and persistence characterization", () => {
|
||||
};
|
||||
const harness = await createHarness({ tools: [echoTool] });
|
||||
harnesses.push(harness);
|
||||
let providerUserTexts: string[] = [];
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
|
||||
(context) => {
|
||||
providerUserTexts = context.messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => getMessageText(message));
|
||||
return fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" });
|
||||
},
|
||||
fauxAssistantMessage("done"),
|
||||
]);
|
||||
|
||||
await harness.session.sendCustomMessage({
|
||||
customType: "note",
|
||||
content: "hello",
|
||||
display: true,
|
||||
details: { a: 1 },
|
||||
});
|
||||
await harness.session.prompt("start");
|
||||
|
||||
const entries = harness.sessionManager.getEntries();
|
||||
expect(entries.map((entry) => entry.type)).toEqual([
|
||||
"custom_message",
|
||||
"message",
|
||||
"message",
|
||||
"message",
|
||||
"message",
|
||||
]);
|
||||
expect(harness.session.messages.map((message) => message.role)).toEqual([
|
||||
"custom",
|
||||
"user",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
"assistant",
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes flagged custom messages from LLM context while preserving them", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
let userTexts: string[] = [];
|
||||
|
||||
await harness.session.sendCustomMessage({
|
||||
customType: "status",
|
||||
content: "status panel",
|
||||
@@ -187,26 +163,37 @@ describe("AgentSession bash and persistence characterization", () => {
|
||||
details: { a: 1 },
|
||||
excludeFromContext: true,
|
||||
});
|
||||
harness.setResponses([
|
||||
(context) => {
|
||||
userTexts = context.messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => getMessageText(message));
|
||||
return fauxAssistantMessage("done");
|
||||
},
|
||||
]);
|
||||
await harness.session.sendCustomMessage({
|
||||
customType: "note",
|
||||
content: "hello",
|
||||
display: true,
|
||||
details: { a: 1 },
|
||||
});
|
||||
await harness.session.prompt("start");
|
||||
|
||||
await harness.session.prompt("next prompt");
|
||||
|
||||
expect(userTexts).toEqual(["next prompt"]);
|
||||
expect(providerUserTexts).toEqual(["hello", "start"]);
|
||||
const entries = harness.sessionManager.getEntries();
|
||||
expect(entries[0]?.type).toBe("custom_message");
|
||||
if (entries[0]?.type !== "custom_message") return;
|
||||
expect(entries[0].excludeFromContext).toBe(true);
|
||||
expect(entries.map((entry) => entry.type)).toEqual([
|
||||
"custom_message",
|
||||
"custom_message",
|
||||
"message",
|
||||
"message",
|
||||
"message",
|
||||
"message",
|
||||
]);
|
||||
expect(entries[0]).toMatchObject({ type: "custom_message", excludeFromContext: true });
|
||||
expect(harness.sessionManager.buildSessionContext().messages[0]).toMatchObject({
|
||||
role: "custom",
|
||||
excludeFromContext: true,
|
||||
});
|
||||
expect(harness.session.messages.map((message) => message.role)).toEqual([
|
||||
"custom",
|
||||
"custom",
|
||||
"user",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
"assistant",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not emit message_end for bash execution messages", async () => {
|
||||
|
||||
@@ -258,96 +258,68 @@ describe("AgentSession queue characterization", () => {
|
||||
expect(getAssistantTexts(harness)).toEqual(["", "original turn complete", "batched follow-up response"]);
|
||||
});
|
||||
|
||||
it("records excluded custom messages with triggerTurn without starting a provider turn", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
it("records excluded custom messages immediately without starting provider turns", async () => {
|
||||
const idleHarness = await createHarness();
|
||||
harnesses.push(idleHarness);
|
||||
let providerCalled = false;
|
||||
harness.setResponses([
|
||||
idleHarness.setResponses([
|
||||
() => {
|
||||
providerCalled = true;
|
||||
return fauxAssistantMessage("unexpected");
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.sendCustomMessage(
|
||||
await idleHarness.session.sendCustomMessage(
|
||||
{ customType: "status", content: "display only", display: true, details: {}, excludeFromContext: true },
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
|
||||
expect(providerCalled).toBe(false);
|
||||
expect(harness.session.messages).toHaveLength(1);
|
||||
expect(harness.session.messages[0]).toMatchObject({
|
||||
expect(idleHarness.session.messages[0]).toMatchObject({
|
||||
role: "custom",
|
||||
customType: "status",
|
||||
excludeFromContext: true,
|
||||
});
|
||||
expect(harness.getPendingResponseCount()).toBe(1);
|
||||
});
|
||||
expect(idleHarness.getPendingResponseCount()).toBe(1);
|
||||
|
||||
it("records excluded custom messages with deliverAs steer while streaming", async () => {
|
||||
const waiting = await createWaitingHarness();
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
let recordedBeforeRelease = false;
|
||||
for (const deliverAs of ["steer", "followUp"] as const) {
|
||||
const waiting = await createWaitingHarness();
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
let providerCalledForQueuedMessage = false;
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
() => {
|
||||
providerCalledForQueuedMessage = true;
|
||||
return fauxAssistantMessage("unexpected queued turn");
|
||||
},
|
||||
]);
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
]);
|
||||
await waitForToolStart;
|
||||
await harness.session.sendCustomMessage(
|
||||
{
|
||||
customType: "status",
|
||||
content: `${deliverAs} display only`,
|
||||
display: true,
|
||||
details: {},
|
||||
excludeFromContext: true,
|
||||
},
|
||||
{ deliverAs },
|
||||
);
|
||||
const recordedBeforeRelease = harness.session.messages.some(
|
||||
(message) => message.role === "custom" && message.customType === "status",
|
||||
);
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
await waitForToolStart;
|
||||
await harness.session.sendCustomMessage(
|
||||
{ customType: "status", content: "steer display only", display: true, details: {}, excludeFromContext: true },
|
||||
{ deliverAs: "steer" },
|
||||
);
|
||||
recordedBeforeRelease = harness.session.messages.some(
|
||||
(message) => message.role === "custom" && message.customType === "status",
|
||||
);
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
expect(recordedBeforeRelease).toBe(true);
|
||||
expect(
|
||||
harness.session.messages.filter((message) => message.role === "custom" && message.customType === "status"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("records excluded custom messages with deliverAs followUp while streaming without starting another turn", async () => {
|
||||
const waiting = await createWaitingHarness();
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
let providerCalledForFollowUp = false;
|
||||
let recordedBeforeRelease = false;
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
() => {
|
||||
providerCalledForFollowUp = true;
|
||||
return fauxAssistantMessage("unexpected follow-up");
|
||||
},
|
||||
]);
|
||||
|
||||
await waitForToolStart;
|
||||
await harness.session.sendCustomMessage(
|
||||
{
|
||||
customType: "status",
|
||||
content: "follow-up display only",
|
||||
display: true,
|
||||
details: {},
|
||||
excludeFromContext: true,
|
||||
},
|
||||
{ deliverAs: "followUp" },
|
||||
);
|
||||
recordedBeforeRelease = harness.session.messages.some(
|
||||
(message) => message.role === "custom" && message.customType === "status",
|
||||
);
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
expect(recordedBeforeRelease).toBe(true);
|
||||
expect(providerCalledForFollowUp).toBe(false);
|
||||
expect(harness.getPendingResponseCount()).toBe(1);
|
||||
expect(recordedBeforeRelease).toBe(true);
|
||||
expect(
|
||||
harness.session.messages.filter((message) => message.role === "custom" && message.customType === "status"),
|
||||
).toHaveLength(1);
|
||||
expect(providerCalledForQueuedMessage).toBe(false);
|
||||
expect(harness.getPendingResponseCount()).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("persists excluded custom messages from message_end hooks after the triggering message", async () => {
|
||||
|
||||
@@ -31,28 +31,6 @@ describe("AgentSession retry and event characterization", () => {
|
||||
});
|
||||
|
||||
it("retries after a transient error and succeeds", async () => {
|
||||
const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } } });
|
||||
harnesses.push(harness);
|
||||
const retryEvents: string[] = [];
|
||||
harness.session.subscribe((event) => {
|
||||
if (event.type === "auto_retry_start") retryEvents.push(`start:${event.attempt}`);
|
||||
if (event.type === "auto_retry_end") retryEvents.push(`end:${event.success}`);
|
||||
});
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
|
||||
fauxAssistantMessage("recovered"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("test");
|
||||
|
||||
expect(retryEvents).toEqual(["start:1", "end:true"]);
|
||||
expect(harness.eventsOfType("agent_end").map((event) => event.willRetry)).toEqual([true, false]);
|
||||
expect(harness.faux.state.callCount).toBe(2);
|
||||
expect(harness.session.isRetrying).toBe(false);
|
||||
});
|
||||
|
||||
it("retries when an excluded custom message follows the transient error", async () => {
|
||||
const harness = await createHarness({
|
||||
settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } },
|
||||
extensionFactories: [
|
||||
@@ -73,6 +51,12 @@ describe("AgentSession retry and event characterization", () => {
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const retryEvents: string[] = [];
|
||||
harness.session.subscribe((event) => {
|
||||
if (event.type === "auto_retry_start") retryEvents.push(`start:${event.attempt}`);
|
||||
if (event.type === "auto_retry_end") retryEvents.push(`end:${event.success}`);
|
||||
});
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
|
||||
fauxAssistantMessage("recovered"),
|
||||
@@ -80,7 +64,10 @@ describe("AgentSession retry and event characterization", () => {
|
||||
|
||||
await harness.session.prompt("test");
|
||||
|
||||
expect(retryEvents).toEqual(["start:1", "end:true"]);
|
||||
expect(harness.eventsOfType("agent_end").map((event) => event.willRetry)).toEqual([true, false]);
|
||||
expect(harness.faux.state.callCount).toBe(2);
|
||||
expect(harness.session.isRetrying).toBe(false);
|
||||
expect(
|
||||
harness.session.messages.some((message) => message.role === "custom" && message.customType === "status"),
|
||||
).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user