Files
oh-my-pi/packages/coding-agent/test/interactive-mode-loop.test.ts
T
roboomp 5573270e5e fix(loop-mode): block auto-submit while post-prompt background work is pending
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
2026-05-22 13:41:17 +00:00

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");
});
});