5573270e5e
When session.prompt() returns, idle-flush tasks for async-job result deliveries are scheduled via #schedulePostPromptTask (1ms delay) and added to #postPromptTasks immediately. The 800ms loop timer could fire in that window before isStreaming became true, causing the loop to submit the next prompt while the delivery turn was still pending. The delivery then hit AgentBusyError and the job result was silently dropped. Add AgentSession.hasPostPromptWork (= #postPromptTasks.size > 0) and include it in #isLoopAutoSubmitBlocked() alongside isStreaming and isCompacting. Add a regression test that verifies the loop defers when hasPostPromptWork is true and fires once it becomes false. Fixes #1294
141 lines
5.1 KiB
TypeScript
141 lines
5.1 KiB
TypeScript
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
import * as path from "node:path";
|
|
import { Agent } from "@oh-my-pi/pi-agent-core";
|
|
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
|
import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { InteractiveMode } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode";
|
|
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
|
import type { SubmittedUserInput } from "@oh-my-pi/pi-coding-agent/modes/types";
|
|
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 { TempDir } from "@oh-my-pi/pi-utils";
|
|
|
|
async function flushMicrotasks(): Promise<void> {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}
|
|
|
|
describe("InteractiveMode loop auto-submit", () => {
|
|
let authStorage: AuthStorage;
|
|
let mode: InteractiveMode;
|
|
let session: AgentSession;
|
|
let tempDir: TempDir;
|
|
|
|
beforeAll(() => {
|
|
initTheme();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
resetSettingsForTest();
|
|
tempDir = TempDir.createSync("@pi-loop-auto-submit-");
|
|
await Settings.init({ inMemory: true, cwd: tempDir.path() });
|
|
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
|
|
const modelRegistry = new ModelRegistry(authStorage);
|
|
const model = modelRegistry.find("anthropic", "claude-sonnet-4-5");
|
|
if (!model) throw new Error("Expected claude-sonnet-4-5 test model");
|
|
|
|
session = new AgentSession({
|
|
agent: new Agent({ initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] } }),
|
|
sessionManager: SessionManager.create(tempDir.path(), tempDir.path()),
|
|
settings: Settings.isolated(),
|
|
modelRegistry,
|
|
});
|
|
mode = new InteractiveMode(session, "test");
|
|
vi.spyOn(mode, "addMessageToChat").mockReturnValue([]);
|
|
vi.spyOn(mode, "ensureLoadingAnimation").mockImplementation(() => {});
|
|
mode.ui.requestRender = vi.fn();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
mode?.disableLoopMode("Loop mode disabled.");
|
|
mode?.stop();
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
await session?.dispose();
|
|
authStorage?.close();
|
|
tempDir?.removeSync();
|
|
resetSettingsForTest();
|
|
});
|
|
|
|
it("does not resolve the next loop prompt while compaction is running", async () => {
|
|
vi.useFakeTimers();
|
|
let compacting = true;
|
|
Object.defineProperty(session, "isCompacting", { configurable: true, get: () => compacting });
|
|
Object.defineProperty(session, "isStreaming", { configurable: true, get: () => false });
|
|
|
|
mode.loopModeEnabled = true;
|
|
mode.loopPrompt = "repeat this";
|
|
const resolved: SubmittedUserInput[] = [];
|
|
void mode.getUserInput().then(input => resolved.push(input));
|
|
|
|
vi.advanceTimersByTime(800);
|
|
await flushMicrotasks();
|
|
expect(resolved).toHaveLength(0);
|
|
|
|
compacting = false;
|
|
vi.advanceTimersByTime(800);
|
|
await flushMicrotasks();
|
|
|
|
expect(resolved).toHaveLength(1);
|
|
expect(resolved[0].text).toBe("repeat this");
|
|
});
|
|
|
|
it("does not recompact when a compact loop turn starts another prompt before resubmitting", async () => {
|
|
vi.useFakeTimers();
|
|
settings.set("loop.mode", "compact");
|
|
let streaming = false;
|
|
Object.defineProperty(session, "isCompacting", { configurable: true, get: () => false });
|
|
Object.defineProperty(session, "isStreaming", { configurable: true, get: () => streaming });
|
|
const compact = vi.spyOn(mode, "handleCompactCommand").mockImplementation(async () => {
|
|
streaming = true;
|
|
return "ok";
|
|
});
|
|
|
|
mode.loopModeEnabled = true;
|
|
mode.loopPrompt = "repeat after compact";
|
|
const resolved: SubmittedUserInput[] = [];
|
|
void mode.getUserInput().then(input => resolved.push(input));
|
|
|
|
vi.advanceTimersByTime(800);
|
|
await flushMicrotasks();
|
|
expect(compact).toHaveBeenCalledTimes(1);
|
|
expect(resolved).toHaveLength(0);
|
|
|
|
streaming = false;
|
|
vi.advanceTimersByTime(800);
|
|
await flushMicrotasks();
|
|
|
|
expect(compact).toHaveBeenCalledTimes(1);
|
|
expect(resolved).toHaveLength(1);
|
|
expect(resolved[0].text).toBe("repeat after compact");
|
|
});
|
|
|
|
it("does not resolve the next loop prompt while post-prompt background work is pending", async () => {
|
|
vi.useFakeTimers();
|
|
let hasPendingWork = true;
|
|
Object.defineProperty(session, "isCompacting", { configurable: true, get: () => false });
|
|
Object.defineProperty(session, "isStreaming", { configurable: true, get: () => false });
|
|
Object.defineProperty(session, "hasPostPromptWork", { configurable: true, get: () => hasPendingWork });
|
|
|
|
mode.loopModeEnabled = true;
|
|
mode.loopPrompt = "deliver this";
|
|
const resolved: SubmittedUserInput[] = [];
|
|
void mode.getUserInput().then(input => resolved.push(input));
|
|
|
|
// Loop timer fires while an idle-flush / delivery turn is still pending.
|
|
vi.advanceTimersByTime(800);
|
|
await flushMicrotasks();
|
|
expect(resolved).toHaveLength(0);
|
|
|
|
// Background delivery completes; loop may now fire.
|
|
hasPendingWork = false;
|
|
vi.advanceTimersByTime(800);
|
|
await flushMicrotasks();
|
|
|
|
expect(resolved).toHaveLength(1);
|
|
expect(resolved[0].text).toBe("deliver this");
|
|
});
|
|
});
|