refactor(runtime): consolidate followup, gateway, and provider dedupe paths

This commit is contained in:
Peter Steinberger
2026-02-22 14:06:03 +00:00
parent 38752338dc
commit d116bcfb14
36 changed files with 848 additions and 908 deletions

View File

@@ -60,8 +60,8 @@ describe("injectHistoryImagesIntoMessages", () => {
});
describe("resolvePromptBuildHookResult", () => {
it("reuses precomputed legacy before_agent_start result without invoking hook again", async () => {
const hookRunner = {
function createLegacyOnlyHookRunner() {
return {
hasHooks: vi.fn(
(hookName: "before_prompt_build" | "before_agent_start") =>
hookName === "before_agent_start",
@@ -69,6 +69,10 @@ describe("resolvePromptBuildHookResult", () => {
runBeforePromptBuild: vi.fn(async () => undefined),
runBeforeAgentStart: vi.fn(async () => ({ prependContext: "from-hook" })),
};
}
it("reuses precomputed legacy before_agent_start result without invoking hook again", async () => {
const hookRunner = createLegacyOnlyHookRunner();
const result = await resolvePromptBuildHookResult({
prompt: "hello",
messages: [],
@@ -85,14 +89,7 @@ describe("resolvePromptBuildHookResult", () => {
});
it("calls legacy hook when precomputed result is absent", async () => {
const hookRunner = {
hasHooks: vi.fn(
(hookName: "before_prompt_build" | "before_agent_start") =>
hookName === "before_agent_start",
),
runBeforePromptBuild: vi.fn(async () => undefined),
runBeforeAgentStart: vi.fn(async () => ({ prependContext: "from-hook" })),
};
const hookRunner = createLegacyOnlyHookRunner();
const messages = [{ role: "user", content: "ctx" }];
const result = await resolvePromptBuildHookResult({
prompt: "hello",

View File

@@ -2,9 +2,15 @@ import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { formatBillingErrorMessage } from "../../pi-embedded-helpers.js";
import { makeAssistantMessageFixture } from "../../test-helpers/assistant-message-fixtures.js";
import { buildEmbeddedRunPayloads } from "./payloads.js";
import {
buildPayloads,
expectSinglePayloadText,
expectSingleToolErrorPayload,
} from "./payloads.test-helpers.js";
describe("buildEmbeddedRunPayloads", () => {
const OVERLOADED_FALLBACK_TEXT =
"The AI service is temporarily overloaded. Please try again in a moment.";
const errorJson =
'{"type":"error","error":{"details":null,"type":"overloaded_error","message":"Overloaded"},"request_id":"req_011CX7DwS7tSvggaNHmefwWg"}';
const errorJsonPretty = `{
@@ -22,31 +28,25 @@ describe("buildEmbeddedRunPayloads", () => {
content: [{ type: "text", text: errorJson }],
...overrides,
});
type BuildPayloadParams = Parameters<typeof buildEmbeddedRunPayloads>[0];
const buildPayloads = (overrides: Partial<BuildPayloadParams> = {}) =>
buildEmbeddedRunPayloads({
assistantTexts: [],
toolMetas: [],
lastAssistant: undefined,
sessionKey: "session:telegram",
inlineToolResultsAllowed: false,
verboseLevel: "off",
reasoningLevel: "off",
toolResultFormat: "plain",
...overrides,
const makeStoppedAssistant = () =>
makeAssistant({
stopReason: "stop",
errorMessage: undefined,
content: [],
});
const expectOverloadedFallback = (payloads: ReturnType<typeof buildPayloads>) => {
expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe(OVERLOADED_FALLBACK_TEXT);
};
it("suppresses raw API error JSON when the assistant errored", () => {
const payloads = buildPayloads({
assistantTexts: [errorJson],
lastAssistant: makeAssistant({}),
});
expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe(
"The AI service is temporarily overloaded. Please try again in a moment.",
);
expectOverloadedFallback(payloads);
expect(payloads[0]?.isError).toBe(true);
expect(payloads.some((payload) => payload.text === errorJson)).toBe(false);
});
@@ -59,10 +59,7 @@ describe("buildEmbeddedRunPayloads", () => {
verboseLevel: "on",
});
expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe(
"The AI service is temporarily overloaded. Please try again in a moment.",
);
expectOverloadedFallback(payloads);
expect(payloads.some((payload) => payload.text === errorJsonPretty)).toBe(false);
});
@@ -71,10 +68,7 @@ describe("buildEmbeddedRunPayloads", () => {
lastAssistant: makeAssistant({ content: [{ type: "text", text: errorJsonPretty }] }),
});
expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe(
"The AI service is temporarily overloaded. Please try again in a moment.",
);
expectOverloadedFallback(payloads);
expect(payloads.some((payload) => payload.text?.includes("request_id"))).toBe(false);
});
@@ -108,15 +102,10 @@ describe("buildEmbeddedRunPayloads", () => {
it("does not suppress error-shaped JSON when the assistant did not error", () => {
const payloads = buildPayloads({
assistantTexts: [errorJsonPretty],
lastAssistant: makeAssistant({
stopReason: "stop",
errorMessage: undefined,
content: [],
}),
lastAssistant: makeStoppedAssistant(),
});
expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe(errorJsonPretty.trim());
expectSinglePayloadText(payloads, errorJsonPretty.trim());
});
it("adds a fallback error when a tool fails and no assistant output exists", () => {
@@ -133,31 +122,21 @@ describe("buildEmbeddedRunPayloads", () => {
it("does not add tool error fallback when assistant output exists", () => {
const payloads = buildPayloads({
assistantTexts: ["All good"],
lastAssistant: makeAssistant({
stopReason: "stop",
errorMessage: undefined,
content: [],
}),
lastAssistant: makeStoppedAssistant(),
lastToolError: { toolName: "browser", error: "tab not found" },
});
expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe("All good");
expectSinglePayloadText(payloads, "All good");
});
it("adds completion fallback when tools run successfully without final assistant text", () => {
const payloads = buildPayloads({
toolMetas: [{ toolName: "write", meta: "/tmp/out.md" }],
lastAssistant: makeAssistant({
stopReason: "stop",
errorMessage: undefined,
content: [],
}),
lastAssistant: makeStoppedAssistant(),
});
expect(payloads).toHaveLength(1);
expectSinglePayloadText(payloads, "✅ Done.");
expect(payloads[0]?.isError).toBeUndefined();
expect(payloads[0]?.text).toBe("✅ Done.");
});
it("does not add completion fallback when the run still has a tool error", () => {
@@ -171,11 +150,7 @@ describe("buildEmbeddedRunPayloads", () => {
it("does not add completion fallback when no tools ran", () => {
const payloads = buildPayloads({
lastAssistant: makeAssistant({
stopReason: "stop",
errorMessage: undefined,
content: [],
}),
lastAssistant: makeStoppedAssistant(),
});
expect(payloads).toHaveLength(0);
@@ -199,10 +174,10 @@ describe("buildEmbeddedRunPayloads", () => {
verboseLevel: "on",
});
expect(payloads).toHaveLength(1);
expect(payloads[0]?.isError).toBe(true);
expect(payloads[0]?.text).toContain("Exec");
expect(payloads[0]?.text).toContain("code 1");
expectSingleToolErrorPayload(payloads, {
title: "Exec",
detail: "code 1",
});
});
it("does not add tool error fallback when assistant text exists after tool calls", () => {

View File

@@ -0,0 +1,41 @@
import { expect } from "vitest";
import { buildEmbeddedRunPayloads } from "./payloads.js";
export type BuildPayloadParams = Parameters<typeof buildEmbeddedRunPayloads>[0];
type RunPayloads = ReturnType<typeof buildEmbeddedRunPayloads>;
export function buildPayloads(overrides: Partial<BuildPayloadParams> = {}) {
return buildEmbeddedRunPayloads({
assistantTexts: [],
toolMetas: [],
lastAssistant: undefined,
sessionKey: "session:telegram",
inlineToolResultsAllowed: false,
verboseLevel: "off",
reasoningLevel: "off",
toolResultFormat: "plain",
...overrides,
});
}
export function expectSinglePayloadText(
payloads: RunPayloads,
text: string,
expectedError?: boolean,
): void {
expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe(text);
if (typeof expectedError === "boolean") {
expect(payloads[0]?.isError).toBe(expectedError);
}
}
export function expectSingleToolErrorPayload(
payloads: RunPayloads,
params: { title: string; detail: string },
): void {
expect(payloads).toHaveLength(1);
expect(payloads[0]?.isError).toBe(true);
expect(payloads[0]?.text).toContain(params.title);
expect(payloads[0]?.text).toContain(params.detail);
}

View File

@@ -1,21 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildEmbeddedRunPayloads } from "./payloads.js";
type BuildPayloadParams = Parameters<typeof buildEmbeddedRunPayloads>[0];
function buildPayloads(overrides: Partial<BuildPayloadParams> = {}) {
return buildEmbeddedRunPayloads({
assistantTexts: [],
toolMetas: [],
lastAssistant: undefined,
sessionKey: "session:telegram",
inlineToolResultsAllowed: false,
verboseLevel: "off",
reasoningLevel: "off",
toolResultFormat: "plain",
...overrides,
});
}
import { buildPayloads, expectSingleToolErrorPayload } from "./payloads.test-helpers.js";
describe("buildEmbeddedRunPayloads tool-error warnings", () => {
it("suppresses exec tool errors when verbose mode is off", () => {
@@ -33,10 +17,10 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => {
verboseLevel: "on",
});
expect(payloads).toHaveLength(1);
expect(payloads[0]?.isError).toBe(true);
expect(payloads[0]?.text).toContain("Exec");
expect(payloads[0]?.text).toContain("command failed");
expectSingleToolErrorPayload(payloads, {
title: "Exec",
detail: "command failed",
});
});
it("keeps non-exec mutating tool failures visible", () => {

View File

@@ -96,22 +96,26 @@ function hasImageBlocks(content: ReadonlyArray<TextContent | ImageContent>): boo
return false;
}
function estimateTextAndImageChars(content: ReadonlyArray<TextContent | ImageContent>): number {
let chars = 0;
for (const block of content) {
if (block.type === "text") {
chars += block.text.length;
}
if (block.type === "image") {
chars += IMAGE_CHAR_ESTIMATE;
}
}
return chars;
}
function estimateMessageChars(message: AgentMessage): number {
if (message.role === "user") {
const content = message.content;
if (typeof content === "string") {
return content.length;
}
let chars = 0;
for (const b of content) {
if (b.type === "text") {
chars += b.text.length;
}
if (b.type === "image") {
chars += IMAGE_CHAR_ESTIMATE;
}
}
return chars;
return estimateTextAndImageChars(content);
}
if (message.role === "assistant") {
@@ -135,16 +139,7 @@ function estimateMessageChars(message: AgentMessage): number {
}
if (message.role === "toolResult") {
let chars = 0;
for (const b of message.content) {
if (b.type === "text") {
chars += b.text.length;
}
if (b.type === "image") {
chars += IMAGE_CHAR_ESTIMATE;
}
}
return chars;
return estimateTextAndImageChars(message.content);
}
return 256;