e805b34ecf
- Added `features.unexpectedStopDetection` and `unexpectedStopModel` settings for opt-in behavior. - Added assistant-stop handling to classify stop reasons and resume generation with retry prompts. - Added unexpected-stop classifier logic with candidate checks, model fallback, and YES/NO parsing. - Added retry tracking that caps auto-continues at three attempts and logs a warning when exceeded.
93 lines
3.0 KiB
TypeScript
93 lines
3.0 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
|
|
import {
|
|
isUnexpectedStopCandidate,
|
|
parseUnexpectedStopClassification,
|
|
} from "@oh-my-pi/pi-coding-agent/session/unexpected-stop-classifier";
|
|
|
|
function makeAssistantMessage(options: {
|
|
stopReason: AssistantMessage["stopReason"];
|
|
content: AssistantMessage["content"];
|
|
}): AssistantMessage {
|
|
return {
|
|
role: "assistant",
|
|
provider: "mock",
|
|
model: "mock/mock",
|
|
api: "mock" as unknown as AssistantMessage["api"],
|
|
content: options.content,
|
|
stopReason: options.stopReason,
|
|
timestamp: Date.now(),
|
|
} as unknown as AssistantMessage;
|
|
}
|
|
|
|
describe("isUnexpectedStopCandidate", () => {
|
|
it("returns true for a text-only stop", () => {
|
|
const message = makeAssistantMessage({
|
|
stopReason: "stop",
|
|
content: [{ type: "text", text: "I should do the same for the JS eval worker." }],
|
|
});
|
|
expect(isUnexpectedStopCandidate(message)).toBe(true);
|
|
});
|
|
|
|
it("returns false when stopReason is not stop", () => {
|
|
const length = makeAssistantMessage({
|
|
stopReason: "length",
|
|
content: [{ type: "text", text: "I should continue." }],
|
|
});
|
|
expect(isUnexpectedStopCandidate(length)).toBe(false);
|
|
|
|
const aborted = makeAssistantMessage({
|
|
stopReason: "aborted",
|
|
content: [{ type: "text", text: "I should continue." }],
|
|
});
|
|
expect(isUnexpectedStopCandidate(aborted)).toBe(false);
|
|
});
|
|
|
|
it("returns false when the message contains a toolCall", () => {
|
|
const message = makeAssistantMessage({
|
|
stopReason: "stop",
|
|
content: [
|
|
{ type: "text", text: "I will run the tests now." },
|
|
{ type: "toolCall", id: "call-1", name: "bash", arguments: {} },
|
|
],
|
|
});
|
|
expect(isUnexpectedStopCandidate(message)).toBe(false);
|
|
});
|
|
|
|
it("returns false when the text is only whitespace", () => {
|
|
const message = makeAssistantMessage({
|
|
stopReason: "stop",
|
|
content: [{ type: "text", text: " \n\t " }],
|
|
});
|
|
expect(isUnexpectedStopCandidate(message)).toBe(false);
|
|
});
|
|
|
|
it("returns false for an empty stop", () => {
|
|
const message = makeAssistantMessage({
|
|
stopReason: "stop",
|
|
content: [],
|
|
});
|
|
expect(isUnexpectedStopCandidate(message)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("parseUnexpectedStopClassification", () => {
|
|
it("returns true for YES output", () => {
|
|
expect(parseUnexpectedStopClassification("YES")).toBe(true);
|
|
expect(parseUnexpectedStopClassification("yes")).toBe(true);
|
|
expect(parseUnexpectedStopClassification(" Yes, this is unexpected ")).toBe(true);
|
|
});
|
|
|
|
it("returns false for NO output", () => {
|
|
expect(parseUnexpectedStopClassification("NO")).toBe(false);
|
|
expect(parseUnexpectedStopClassification("no")).toBe(false);
|
|
expect(parseUnexpectedStopClassification("No, the task is complete.")).toBe(false);
|
|
});
|
|
|
|
it("returns undefined for unparseable output", () => {
|
|
expect(parseUnexpectedStopClassification("maybe")).toBeUndefined();
|
|
expect(parseUnexpectedStopClassification("")).toBeUndefined();
|
|
expect(parseUnexpectedStopClassification("I don't know")).toBeUndefined();
|
|
});
|
|
});
|