Second upstream merge (upstream/main advanced 24 more commits) pulled
in two independent behavior changes that broke tests I'd already
touched or that existed:
- packages/ai: commits cda6df81d/d7f070d44 added
preserveAssistantMessageIds for Azure specifically (azure sets it
true), so the earlier fix stripping msg ids in
azure-openai-responses-stream.test.ts is now wrong for Azure — it
should preserve them, unlike the plain OpenAI path. Restored the
id/phase assertions and renamed both tests to describe the
preserve-not-omit contract.
- packages/coding-agent: commit 0e2feab74 (refactor: reduced session
log footprint) deleted the
'#agentContinueState'/'agent.continue failed state after
scheduling' debug diagnostics and changed
'agent active context assistant removal' to fire only on a miss,
renamed to 'agent active context assistant removal missed' with a
smaller payload (no more removed/beforeLength/afterLength/etc).
agent-session-retry-diagnostics.test.ts still asserted the old
shape and waited on a debug call that no longer exists, so it hung
for the full 5s timeout. Updated to match the new log name/payload
and dropped the removed diagnostic assertion; the retry-recovery
behavior itself is unchanged and still exercised end to end.
155 lines
5.2 KiB
TypeScript
155 lines
5.2 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
import * as path from "node:path";
|
|
import { scheduler } from "node:timers/promises";
|
|
import { Agent } from "@oh-my-pi/pi-agent-core";
|
|
import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
|
|
import * as AIError from "@oh-my-pi/pi-ai/error";
|
|
import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
|
|
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
|
|
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
|
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
|
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
|
import { logger, TempDir } from "@oh-my-pi/pi-utils";
|
|
|
|
function emptyUsage(): AssistantMessage["usage"] {
|
|
return {
|
|
input: 0,
|
|
output: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
totalTokens: 0,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
};
|
|
}
|
|
|
|
function transientErrorStream(model: Model<Api>): AssistantMessageEventStream {
|
|
const stream = new AssistantMessageEventStream();
|
|
queueMicrotask(() => {
|
|
const message: AssistantMessage = {
|
|
role: "assistant",
|
|
content: [],
|
|
api: model.api,
|
|
provider: model.provider,
|
|
model: model.id,
|
|
usage: emptyUsage(),
|
|
stopReason: "error",
|
|
errorMessage: "socket closed",
|
|
errorId: AIError.create(AIError.Flag.Transient),
|
|
timestamp: Date.now(),
|
|
};
|
|
stream.push({ type: "error", reason: "error", error: message });
|
|
});
|
|
return stream;
|
|
}
|
|
|
|
function successStream(model: Model<Api>): AssistantMessageEventStream {
|
|
const stream = new AssistantMessageEventStream();
|
|
queueMicrotask(() => {
|
|
const message: AssistantMessage = {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "recovered" }],
|
|
api: model.api,
|
|
provider: model.provider,
|
|
model: model.id,
|
|
usage: emptyUsage(),
|
|
stopReason: "stop",
|
|
timestamp: Date.now(),
|
|
};
|
|
stream.push({ type: "done", reason: "stop", message });
|
|
});
|
|
return stream;
|
|
}
|
|
|
|
async function waitFor(predicate: () => boolean, timeoutMs = 500): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (predicate()) return;
|
|
await Bun.sleep(1);
|
|
}
|
|
throw new Error("Timed out waiting for retry diagnostics");
|
|
}
|
|
|
|
function isRemovalMissDebugCall(call: unknown[]): boolean {
|
|
const payload = call[1];
|
|
return (
|
|
call[0] === "agent active context assistant removal missed" &&
|
|
typeof payload === "object" &&
|
|
payload !== null &&
|
|
"reason" in payload &&
|
|
payload.reason === "auto-retry"
|
|
);
|
|
}
|
|
|
|
describe("AgentSession retry diagnostics", () => {
|
|
let tempDir: TempDir;
|
|
let authStorage: AuthStorage;
|
|
let session: AgentSession | undefined;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = TempDir.createSync("@pi-retry-diagnostics-");
|
|
authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
|
|
authStorage.setRuntimeApiKey("openrouter", "openrouter-test-key");
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (session) {
|
|
await session.dispose();
|
|
session = undefined;
|
|
}
|
|
authStorage.close();
|
|
tempDir.removeSync();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("logs the active-context state when retry removal misses the assistant tail", async () => {
|
|
const model = createMockModel({ provider: "openrouter", id: "glm-test" }).model;
|
|
const calls: string[] = [];
|
|
const agent = new Agent({
|
|
getApiKey: requestedModel => `${requestedModel.provider}-test-key`,
|
|
initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] },
|
|
streamFn: requestedModel => {
|
|
calls.push(`${requestedModel.provider}/${requestedModel.id}`);
|
|
if (calls.length === 1) return transientErrorStream(requestedModel);
|
|
return successStream(requestedModel);
|
|
},
|
|
});
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.enabled": true,
|
|
"retry.baseDelayMs": 0,
|
|
"retry.maxRetries": 1,
|
|
"retry.modelFallback": false,
|
|
"todo.enabled": false,
|
|
});
|
|
settings.setModelRole("default", `${model.provider}/${model.id}`);
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry: new ModelRegistry(authStorage),
|
|
});
|
|
agent.subscribe(event => {
|
|
if (event.type !== "message_end" || event.message.role !== "assistant") return;
|
|
if (event.message.stopReason !== "error") return;
|
|
const messages = agent.state.messages;
|
|
const last = messages.at(-1);
|
|
if (last?.role !== "assistant") return;
|
|
agent.replaceMessages([...messages.slice(0, -1), { ...last, timestamp: last.timestamp + 1 }]);
|
|
});
|
|
vi.spyOn(scheduler, "wait").mockResolvedValue(undefined);
|
|
const debugSpy = vi.spyOn(logger, "debug").mockImplementation(() => {});
|
|
|
|
const promptPromise = session.prompt("trigger transient error").catch(() => undefined);
|
|
await waitFor(() => debugSpy.mock.calls.some(isRemovalMissDebugCall));
|
|
|
|
expect(debugSpy.mock.calls).toContainEqual([
|
|
"agent active context assistant removal missed",
|
|
expect.objectContaining({ reason: "auto-retry", lastRole: "assistant" }),
|
|
]);
|
|
await session.abort();
|
|
await promptPromise;
|
|
});
|
|
});
|