mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { ForwardingManager } from "#src/authority/forwarding-manager";
|
|
import {
|
|
type ServingAnnouncer,
|
|
ServingSessionRegistry,
|
|
} from "#src/authority/serving-registry";
|
|
import type { SubagentDetector } from "#src/authority/subagent-detection";
|
|
|
|
// ── Mocks ─────────────────────────────────────────────────────────────────
|
|
|
|
const mockProcessInbox = vi.fn((): Promise<void> => Promise.resolve());
|
|
const mockIsSubagent = vi.fn((): boolean => false);
|
|
const mockReview = vi.fn();
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
function makeCtx(overrides: { hasUI?: boolean; sessionId?: string } = {}) {
|
|
return {
|
|
hasUI: overrides.hasUI ?? true,
|
|
sessionManager: {
|
|
getSessionId: vi.fn().mockReturnValue(overrides.sessionId ?? "sess-1"),
|
|
},
|
|
cwd: "/project",
|
|
} as unknown as import("@earendil-works/pi-coding-agent").ExtensionContext;
|
|
}
|
|
|
|
function makeForwarder() {
|
|
return { processInbox: mockProcessInbox };
|
|
}
|
|
|
|
function makeDetection(): SubagentDetector {
|
|
return { isSubagent: mockIsSubagent };
|
|
}
|
|
|
|
/** A `ServingAnnouncer` whose calls can be counted, for the refresh tests. */
|
|
function makeAnnouncer() {
|
|
return { markServing: vi.fn(), clearServing: vi.fn() };
|
|
}
|
|
|
|
function makeManager(serving: ServingAnnouncer = new ServingSessionRegistry()) {
|
|
return new ForwardingManager({
|
|
detection: makeDetection(),
|
|
forwarder: makeForwarder(),
|
|
serving,
|
|
logger: { review: mockReview, debug: vi.fn() },
|
|
});
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
|
|
describe("ForwardingManager", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockIsSubagent.mockReset();
|
|
mockIsSubagent.mockReturnValue(false);
|
|
mockProcessInbox.mockReset();
|
|
mockProcessInbox.mockResolvedValue(undefined);
|
|
mockReview.mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe("stop()", () => {
|
|
it("is a no-op when not started", () => {
|
|
const manager = makeManager();
|
|
expect(() => manager.stop()).not.toThrow();
|
|
});
|
|
|
|
it("clears the timer and processing state after start()", async () => {
|
|
const manager = makeManager();
|
|
const ctx = makeCtx();
|
|
manager.start(ctx);
|
|
manager.stop();
|
|
|
|
// After stop, the timer fires no more callbacks.
|
|
mockProcessInbox.mockClear();
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(mockProcessInbox).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("start()", () => {
|
|
it("does not start polling when hasUI is false", async () => {
|
|
const manager = makeManager();
|
|
const ctx = makeCtx({ hasUI: false });
|
|
manager.start(ctx);
|
|
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(mockProcessInbox).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("stops any existing poll and does not start a new one when hasUI is false", async () => {
|
|
const manager = makeManager();
|
|
const uiCtx = makeCtx({ hasUI: true });
|
|
const noUiCtx = makeCtx({ hasUI: false });
|
|
|
|
manager.start(uiCtx);
|
|
// Now stop the polling by calling start() with no-UI ctx.
|
|
manager.start(noUiCtx);
|
|
|
|
mockProcessInbox.mockClear();
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(mockProcessInbox).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not start polling when the detector reports a subagent context", async () => {
|
|
mockIsSubagent.mockReturnValue(true);
|
|
const manager = makeManager();
|
|
const ctx = makeCtx();
|
|
manager.start(ctx);
|
|
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(mockProcessInbox).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("stops any existing poll when called with a subagent context", async () => {
|
|
mockIsSubagent.mockReturnValueOnce(false);
|
|
const manager = makeManager();
|
|
const ctx1 = makeCtx();
|
|
manager.start(ctx1);
|
|
|
|
// Second call with a subagent context.
|
|
mockIsSubagent.mockReturnValue(true);
|
|
const ctx2 = makeCtx();
|
|
manager.start(ctx2);
|
|
|
|
mockProcessInbox.mockClear();
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(mockProcessInbox).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("starts polling and calls processInbox on tick", async () => {
|
|
const manager = makeManager();
|
|
const ctx = makeCtx();
|
|
manager.start(ctx);
|
|
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
expect(mockProcessInbox).toHaveBeenCalledWith(ctx);
|
|
});
|
|
|
|
it("is idempotent — calling start() twice does not create a second timer", async () => {
|
|
const manager = makeManager();
|
|
const ctx = makeCtx();
|
|
manager.start(ctx);
|
|
manager.start(ctx);
|
|
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
// Only one tick should fire per interval, not two.
|
|
expect(mockProcessInbox).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("updates the context when called again while already running", async () => {
|
|
const manager = makeManager();
|
|
const ctx1 = makeCtx({ sessionId: "sess-1" });
|
|
const ctx2 = makeCtx({ sessionId: "sess-2" });
|
|
manager.start(ctx1);
|
|
manager.start(ctx2);
|
|
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
// The process call should use the newer context.
|
|
expect(mockProcessInbox).toHaveBeenCalledWith(ctx2);
|
|
});
|
|
|
|
it("skips a tick while processing is in progress", async () => {
|
|
// Make processInbox hang so processing=true persists.
|
|
let resolveProcess: () => void;
|
|
mockProcessInbox.mockReturnValue(
|
|
new Promise<void>((resolve) => {
|
|
resolveProcess = resolve;
|
|
}),
|
|
);
|
|
|
|
const manager = makeManager();
|
|
const ctx = makeCtx();
|
|
manager.start(ctx);
|
|
|
|
// First tick starts processing.
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
expect(mockProcessInbox).toHaveBeenCalledTimes(1);
|
|
|
|
// Second tick is skipped because processing flag is still true.
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
expect(mockProcessInbox).toHaveBeenCalledTimes(1);
|
|
|
|
// Resolve and a third tick should fire.
|
|
resolveProcess!();
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
expect(mockProcessInbox).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("consults the detector with the current context", () => {
|
|
const manager = makeManager();
|
|
const ctx = makeCtx();
|
|
manager.start(ctx);
|
|
|
|
expect(mockIsSubagent).toHaveBeenCalledWith(ctx);
|
|
});
|
|
});
|
|
|
|
describe("serving announcement", () => {
|
|
it("marks the polled session as serving", () => {
|
|
const serving = new ServingSessionRegistry();
|
|
makeManager(serving).start(makeCtx({ sessionId: "sess-1" }));
|
|
|
|
expect(serving.servingIds()).toEqual(["sess-1"]);
|
|
});
|
|
|
|
it("logs the polled session id once per session", () => {
|
|
const manager = makeManager();
|
|
const ctx = makeCtx({ sessionId: "sess-1" });
|
|
manager.start(ctx);
|
|
manager.start(ctx);
|
|
|
|
expect(mockReview).toHaveBeenCalledExactlyOnceWith(
|
|
"forwarded_permission.serving_started",
|
|
{ sessionId: "sess-1" },
|
|
);
|
|
});
|
|
|
|
it("clears the mark on stop()", () => {
|
|
const serving = new ServingSessionRegistry();
|
|
const manager = makeManager(serving);
|
|
manager.start(makeCtx({ sessionId: "sess-1" }));
|
|
manager.stop();
|
|
|
|
expect(serving.servingIds()).toEqual([]);
|
|
});
|
|
|
|
it("logs serving_stopped only when it was serving", () => {
|
|
const manager = makeManager();
|
|
manager.stop();
|
|
expect(mockReview).not.toHaveBeenCalled();
|
|
|
|
manager.start(makeCtx({ sessionId: "sess-1" }));
|
|
mockReview.mockClear();
|
|
manager.stop();
|
|
|
|
expect(mockReview).toHaveBeenCalledExactlyOnceWith(
|
|
"forwarded_permission.serving_stopped",
|
|
{ sessionId: "sess-1" },
|
|
);
|
|
});
|
|
|
|
it("moves the mark when the session id changes", () => {
|
|
const serving = new ServingSessionRegistry();
|
|
const manager = makeManager(serving);
|
|
manager.start(makeCtx({ sessionId: "sess-1" }));
|
|
manager.start(makeCtx({ sessionId: "sess-2" }));
|
|
|
|
expect(serving.servingIds()).toEqual(["sess-2"]);
|
|
});
|
|
|
|
it("clears the mark when a later context no longer qualifies", () => {
|
|
const serving = new ServingSessionRegistry();
|
|
const manager = makeManager(serving);
|
|
manager.start(makeCtx({ sessionId: "sess-1" }));
|
|
manager.start(makeCtx({ sessionId: "sess-1", hasUI: false }));
|
|
|
|
expect(serving.servingIds()).toEqual([]);
|
|
});
|
|
|
|
it("never marks a session it does not poll", () => {
|
|
const serving = new ServingSessionRegistry();
|
|
makeManager(serving).start(
|
|
makeCtx({ sessionId: "sess-1", hasUI: false }),
|
|
);
|
|
|
|
expect(serving.servingIds()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("serving refresh", () => {
|
|
it("re-announces on every poll tick, so the announcement cannot decay", async () => {
|
|
const serving = makeAnnouncer();
|
|
makeManager(serving).start(makeCtx({ sessionId: "sess-1" }));
|
|
serving.markServing.mockClear();
|
|
|
|
await vi.advanceTimersByTimeAsync(750);
|
|
|
|
expect(serving.markServing).toHaveBeenCalledTimes(3);
|
|
expect(serving.markServing).toHaveBeenCalledWith("sess-1");
|
|
});
|
|
|
|
it("re-announces while a drain is still in flight", async () => {
|
|
// A human deliberating at a forwarded dialog holds `processInbox` open
|
|
// for as long as they take. That session is serving, and must not read as
|
|
// gone to another child while it waits — so the refresh cannot sit behind
|
|
// the processing guard.
|
|
mockProcessInbox.mockReturnValue(new Promise<void>(() => undefined));
|
|
const serving = makeAnnouncer();
|
|
makeManager(serving).start(makeCtx({ sessionId: "sess-1" }));
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
expect(mockProcessInbox).toHaveBeenCalledTimes(1);
|
|
serving.markServing.mockClear();
|
|
|
|
await vi.advanceTimersByTimeAsync(750);
|
|
|
|
expect(serving.markServing).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("adds no review entry per refresh", async () => {
|
|
makeManager(makeAnnouncer()).start(makeCtx({ sessionId: "sess-1" }));
|
|
mockReview.mockClear();
|
|
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
|
|
expect(mockReview).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("stops re-announcing once stopped", async () => {
|
|
const serving = makeAnnouncer();
|
|
const manager = makeManager(serving);
|
|
manager.start(makeCtx({ sessionId: "sess-1" }));
|
|
manager.stop();
|
|
serving.markServing.mockClear();
|
|
|
|
await vi.advanceTimersByTimeAsync(750);
|
|
|
|
expect(serving.markServing).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|