388e6462c3
- Cleared native scrollback on `omp`/`omp -c` so the resumed transcript no longer stacks on the prior run's welcome screen. - Tracked assistant block finalization so aborted streaming messages stay repaintable when status rows land beneath them on ED3-risk terminals.
160 lines
6.8 KiB
TypeScript
160 lines
6.8 KiB
TypeScript
/**
|
|
* Regression test: renderInitialMessages must not call buildSessionContext when
|
|
* a prebuilt context is supplied (e.g. from navigateTree's return value).
|
|
*
|
|
* Before the fix, renderInitialMessages always called sessionManager.buildSessionContext(),
|
|
* duplicating the O(N) walk already done inside navigateTree. After the fix,
|
|
* renderInitialMessages accepts an optional prebuiltContext and skips the walk when provided.
|
|
*
|
|
* What these tests cover:
|
|
* - UiHelpers.renderInitialMessages skip/use of buildSessionContext (isolated unit tests).
|
|
* - End-to-end callcount using a real in-memory SessionManager: one build call total when
|
|
* the context is passed through, confirming the handoff contract works at runtime.
|
|
*
|
|
* What is NOT covered here (requires full AgentSession wiring):
|
|
* - That AgentSession.navigateTree actually sets result.sessionContext — enforced by
|
|
* the TypeScript return type and the agent-session unit tests.
|
|
*/
|
|
|
|
import { beforeAll, describe, expect, it, type Mock, vi } from "bun:test";
|
|
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
|
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
|
|
import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers";
|
|
import type { SessionContext } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
|
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
|
|
|
beforeAll(() => {
|
|
initTheme();
|
|
});
|
|
|
|
/** Minimal empty SessionContext (what navigateTree returns when leafId is null). */
|
|
function makeEmptyContext(): SessionContext {
|
|
return {
|
|
messages: [],
|
|
thinkingLevel: "off",
|
|
serviceTier: undefined,
|
|
models: {},
|
|
injectedTtsrRules: [],
|
|
selectedMCPToolNames: [],
|
|
hasPersistedMCPToolSelection: false,
|
|
mode: "none",
|
|
};
|
|
}
|
|
|
|
/** Build a minimal InteractiveModeContext mock, returning spies for assertions. */
|
|
function makeCtx(sessionManager?: Pick<SessionManager, "buildSessionContext" | "getEntries" | "getCwd">): {
|
|
ctx: InteractiveModeContext;
|
|
buildSessionContextSpy: Mock<() => SessionContext>;
|
|
renderSessionContextSpy: Mock<(...args: unknown[]) => void>;
|
|
} {
|
|
const buildSessionContextSpy = vi.fn(() => makeEmptyContext());
|
|
const renderSessionContextSpy = vi.fn();
|
|
|
|
const sm = sessionManager ?? {
|
|
buildSessionContext: buildSessionContextSpy,
|
|
getEntries: vi.fn(() => []),
|
|
getCwd: vi.fn(() => "/tmp"),
|
|
};
|
|
|
|
const ctx = {
|
|
chatContainer: { clear: vi.fn(), addChild: vi.fn() },
|
|
pendingMessagesContainer: { clear: vi.fn() },
|
|
pendingBashComponents: [],
|
|
pendingPythonComponents: [],
|
|
sessionManager: sm,
|
|
renderSessionContext: renderSessionContextSpy,
|
|
showStatus: vi.fn(),
|
|
ui: { requestRender: vi.fn() },
|
|
} as unknown as InteractiveModeContext;
|
|
|
|
return { ctx, buildSessionContextSpy, renderSessionContextSpy };
|
|
}
|
|
|
|
// ─── Part 1: Isolated renderInitialMessages behaviour ─────────────────────────
|
|
|
|
describe("UiHelpers.renderInitialMessages — isolated", () => {
|
|
it("calls sessionManager.buildSessionContext when no prebuilt context is given", () => {
|
|
const { ctx, buildSessionContextSpy } = makeCtx();
|
|
new UiHelpers(ctx).renderInitialMessages();
|
|
expect(buildSessionContextSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does NOT call sessionManager.buildSessionContext when a prebuilt context is provided", () => {
|
|
const { ctx, buildSessionContextSpy } = makeCtx();
|
|
new UiHelpers(ctx).renderInitialMessages(makeEmptyContext());
|
|
expect(buildSessionContextSpy).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it("passes the prebuilt context directly to renderSessionContext", () => {
|
|
const { ctx, renderSessionContextSpy } = makeCtx();
|
|
const prebuilt = makeEmptyContext();
|
|
new UiHelpers(ctx).renderInitialMessages(prebuilt);
|
|
expect(renderSessionContextSpy).toHaveBeenCalledWith(prebuilt, {
|
|
updateFooter: true,
|
|
populateHistory: true,
|
|
});
|
|
});
|
|
|
|
it("uses the fallback context from sessionManager when no prebuilt is provided", () => {
|
|
const fallback = makeEmptyContext();
|
|
const { ctx, renderSessionContextSpy } = makeCtx();
|
|
(ctx.sessionManager.buildSessionContext as Mock<() => SessionContext>).mockReturnValue(fallback);
|
|
new UiHelpers(ctx).renderInitialMessages();
|
|
expect(renderSessionContextSpy).toHaveBeenCalledWith(fallback, {
|
|
updateFooter: true,
|
|
populateHistory: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Cold-launch terminal cleanup ────────────────────────────────────────────
|
|
//
|
|
// `omp` / `omp -c` leave the previous run's transcript in native scrollback
|
|
// because the TUI's initial paint preserves it. The cold-launch render must
|
|
// therefore request a scrollback-clearing repaint (`clearTerminalHistory`) so
|
|
// the resumed transcript replaces the stale one instead of stacking on it.
|
|
// Every in-process session load already does this; this guards the cold path.
|
|
|
|
describe("UiHelpers.renderInitialMessages — clearTerminalHistory", () => {
|
|
it("requests a scrollback-clearing repaint when clearTerminalHistory is set", () => {
|
|
const { ctx } = makeCtx();
|
|
new UiHelpers(ctx).renderInitialMessages(undefined, { clearTerminalHistory: true });
|
|
expect(ctx.ui.requestRender).toHaveBeenCalledWith(true, { clearScrollback: true });
|
|
});
|
|
|
|
it("never clears scrollback when clearTerminalHistory is unset", () => {
|
|
const { ctx } = makeCtx();
|
|
new UiHelpers(ctx).renderInitialMessages();
|
|
const clearedCall = (ctx.ui.requestRender as Mock<(...a: unknown[]) => void>).mock.calls.find(
|
|
([force, opts]) => force === true && (opts as { clearScrollback?: boolean } | undefined)?.clearScrollback,
|
|
);
|
|
expect(clearedCall).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ─── Part 2: End-to-end callcount with a real SessionManager ─────────────────
|
|
//
|
|
// Simulates the selector-controller handoff: buildSessionContext is called once
|
|
// (representing navigateTree's internal call), and the result is passed directly
|
|
// to renderInitialMessages — which must NOT call it a second time.
|
|
|
|
describe("renderInitialMessages callcount with real SessionManager", () => {
|
|
it("total buildSessionContext calls == 1 when context is threaded from navigate to render", async () => {
|
|
const sm = SessionManager.inMemory("/tmp/test");
|
|
// Append a user message with the required timestamp field.
|
|
sm.appendMessage({ role: "user", content: "hello", timestamp: Date.now() });
|
|
|
|
const spy = vi.spyOn(sm, "buildSessionContext");
|
|
|
|
// Step 1: one call — represents navigateTree's internal rawContext build.
|
|
const context = sm.buildSessionContext();
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
|
|
// Step 2: renderInitialMessages with the prebuilt context must NOT call it again.
|
|
const { ctx } = makeCtx(sm as unknown as Parameters<typeof makeCtx>[0]);
|
|
new UiHelpers(ctx).renderInitialMessages(context);
|
|
|
|
expect(spy).toHaveBeenCalledTimes(1); // still exactly 1 — second walk eliminated
|
|
});
|
|
});
|