Files
oh-my-pi/packages/coding-agent/test/agent-session-bash-session-ownership.test.ts
T
roboomp e6de8142fe fix(session): retained bash ownership across session/branch transitions
Late user-initiated bash results and minimized-output artifacts were
recorded against whichever session or branch was active when execution
finished, not the one that started it. executeBash() awaited the result
then wrote through the mutable live sessionManager, while pending bash
messages carried no session/branch ownership and the minimized-output
callback used the live manager. A session change during execution
redirected transcript entries and artifacts to the replacement session
or branch, and a dropped session could be recreated by a straggler.

Capture a bash ownership target when execution starts and transition it
whenever the active session or transcript leaf changes. Late results and
minimized artifacts route to the originating session/branch (via a
detached clone when the file changed, or an off-leaf branch append when
the leaf moved), are discarded for an intentional drop, and ownership is
released on failed transitions. RPC dispatch concurrency and immediate
abort_bash behavior are unchanged.

Fixes #5743
2026-07-16 19:56:27 +00:00

390 lines
14 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import * as bashExecutor from "@oh-my-pi/pi-coding-agent/exec/bash-executor";
import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
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";
import { createAssistantMessage } from "./helpers/agent-session-setup";
const bashResult = {
output: "old-output",
exitCode: 0,
cancelled: false,
truncated: false,
totalLines: 1,
totalBytes: 10,
outputLines: 1,
outputBytes: 10,
};
describe("AgentSession bash session ownership", () => {
let tempDir: TempDir;
let authStorage: AuthStorage;
let session: AgentSession;
let additionalManagers: SessionManager[];
beforeEach(async () => {
tempDir = TempDir.createSync("@pi-bash-session-owner-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
additionalManagers = [];
});
afterEach(async () => {
vi.restoreAllMocks();
await session?.dispose();
await Promise.all(additionalManagers.map(manager => manager.close()));
authStorage.close();
tempDir.removeSync();
});
function createSession(
sessionManager: SessionManager = SessionManager.inMemory(tempDir.path()),
extensionRunner?: ExtensionRunner,
responseContent: () => string[] = () => ["Done"],
): AgentSession {
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist");
const mock = createMockModel({ handler: () => ({ content: responseContent() }) });
const agent = new Agent({
getApiKey: () => "test-key",
initialState: { model, systemPrompt: ["Test"], tools: [] },
streamFn: mock.stream,
});
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
session = new AgentSession({
agent,
sessionManager,
settings: Settings.isolated({ "compaction.enabled": false }),
modelRegistry,
extensionRunner,
});
return session;
}
function createGatedBashRunner() {
const completion = Promise.withResolvers<{ result: typeof bashResult }>();
const emitUserBash = vi.fn(() => completion.promise);
const extensionRunner = {
hasHandlers: vi.fn((eventType: string) => eventType === "user_bash"),
emitUserBash,
emit: vi.fn().mockResolvedValue(undefined),
emitBeforeAgentStart: vi.fn().mockResolvedValue(undefined),
} as unknown as ExtensionRunner;
return { completion, emitUserBash, extensionRunner };
}
async function seedPersistedSession(): Promise<string> {
await session.prompt("seed prompt");
await session.waitForIdle();
const sessionFile = session.sessionFile;
if (!sessionFile) throw new Error("Expected persisted session file");
return sessionFile;
}
it("does not flush a pending bash result into a replacement session", async () => {
createSession();
let forceStreaming = true;
Object.defineProperty(session, "isStreaming", {
configurable: true,
get: () => forceStreaming,
});
const oldSessionId = session.sessionId;
session.recordBashResult("old-session-command", bashResult);
expect(session.hasPendingBashMessages).toBe(true);
forceStreaming = false;
await session.newSession();
expect(session.sessionId).not.toBe(oldSessionId);
expect(session.hasPendingBashMessages).toBe(false);
await session.prompt("new-session-prompt");
await session.waitForIdle();
expect(session.messages.some(message => message.role === "bashExecution")).toBe(false);
});
it("keeps a queued bash result on the branch discarded by an empty stop", async () => {
const sessionManager = SessionManager.inMemory(tempDir.path());
let returnEmptyStop = true;
createSession(sessionManager, undefined, () => (returnEmptyStop ? [] : ["Done"]));
let forceStreaming = false;
Object.defineProperty(session, "isStreaming", {
configurable: true,
get: () => forceStreaming,
});
let discardedAssistantTimestamp: number | undefined;
const unsubscribe = session.agent.subscribe(event => {
if (event.type === "message_end" && event.message.role === "assistant" && returnEmptyStop) {
forceStreaming = true;
discardedAssistantTimestamp = event.message.timestamp;
session.recordBashResult("discarded-turn-command", bashResult);
} else if (event.type === "agent_end") {
forceStreaming = false;
}
});
const started = await session.sendCustomMessage(
{
customType: "ownership-test",
content: "Run an accepted empty turn",
display: false,
attribution: "agent",
},
{ deliverAs: "nextTurn", triggerTurn: true, acceptTerminalEmptyStop: true },
);
unsubscribe();
expect(started).toBe(true);
expect(session.hasPendingBashMessages).toBe(true);
const discardedAssistantEntry = sessionManager
.getEntries()
.find(
entry =>
entry.type === "message" &&
entry.message.role === "assistant" &&
entry.message.timestamp === discardedAssistantTimestamp,
);
if (!discardedAssistantEntry) throw new Error("Expected discarded assistant entry");
returnEmptyStop = false;
await session.prompt("flush queued bash result");
await session.waitForIdle();
const bashEntry = sessionManager
.getEntries()
.find(
entry =>
entry.type === "message" &&
entry.message.role === "bashExecution" &&
entry.message.command === "discarded-turn-command",
);
expect(bashEntry?.parentId).toBe(discardedAssistantEntry.id);
expect(
sessionManager
.getBranch()
.some(
entry =>
entry.type === "message" &&
entry.message.role === "bashExecution" &&
entry.message.command === "discarded-turn-command",
),
).toBe(false);
});
it("releases the bash owner when session transition preparation fails", async () => {
const sessionDir = path.join(tempDir.path(), "sessions");
const { completion, emitUserBash, extensionRunner } = createGatedBashRunner();
createSession(SessionManager.create(tempDir.path(), sessionDir), extensionRunner);
await seedPersistedSession();
const oldSessionId = session.sessionId;
const bashPromise = session.executeBash("old-session-command");
expect(emitUserBash).toHaveBeenCalledTimes(1);
vi.spyOn(session.sessionManager, "flush").mockRejectedValueOnce(new Error("synthetic flush failure"));
await expect(session.newSession()).rejects.toThrow("synthetic flush failure");
expect(session.sessionId).toBe(oldSessionId);
completion.resolve({ result: bashResult });
const settledResult = await bashPromise;
expect(settledResult).toEqual(bashResult);
expect(
session.messages.some(
message => message.role === "bashExecution" && message.command === "old-session-command",
),
).toBe(true);
});
it.each([
"new",
"switch",
"branch",
] as const)("records a late bash result in its original session after %s", async transition => {
const sessionDir = path.join(tempDir.path(), "sessions");
const { completion, emitUserBash, extensionRunner } = createGatedBashRunner();
createSession(SessionManager.create(tempDir.path(), sessionDir), extensionRunner);
const oldSessionFile = await seedPersistedSession();
const oldSessionId = session.sessionId;
const bashPromise = session.executeBash("old-session-command");
expect(emitUserBash).toHaveBeenCalledTimes(1);
switch (transition) {
case "new":
await session.newSession();
break;
case "switch": {
const targetManager = SessionManager.create(tempDir.path(), sessionDir);
targetManager.appendMessage({ role: "user", content: "target", timestamp: Date.now() });
targetManager.appendMessage(createAssistantMessage("target reply"));
await targetManager.ensureOnDisk();
const targetFile = targetManager.getSessionFile();
if (!targetFile) throw new Error("Expected target session file");
await targetManager.close();
await session.switchSession(targetFile);
break;
}
case "branch": {
const userEntry = session.sessionManager
.getEntries()
.find(entry => entry.type === "message" && entry.message.role === "user");
if (!userEntry) throw new Error("Expected user entry for branch");
await session.branch(userEntry.id);
break;
}
}
expect(session.sessionId).not.toBe(oldSessionId);
completion.resolve({ result: bashResult });
await bashPromise;
expect(
session.messages.some(
message => message.role === "bashExecution" && message.command === "old-session-command",
),
).toBe(false);
const oldSession = await SessionManager.open(oldSessionFile, sessionDir, undefined, {
initialCwd: tempDir.path(),
suppressBreadcrumb: true,
});
additionalManagers.push(oldSession);
const oldMessages = oldSession.getBranch().flatMap(entry => (entry.type === "message" ? [entry.message] : []));
expect(oldMessages.slice(-3).map(message => message.role)).toEqual(["user", "assistant", "bashExecution"]);
expect(oldMessages.at(-1)).toMatchObject({
role: "bashExecution",
command: "old-session-command",
output: "old-output",
});
});
it("stores minimized bash output with the originating session", async () => {
const sessionDir = path.join(tempDir.path(), "sessions");
createSession(SessionManager.create(tempDir.path(), sessionDir));
const oldSessionFile = await seedPersistedSession();
const bashStarted = Promise.withResolvers<void>();
const finishBash = Promise.withResolvers<void>();
let artifactId: string | undefined;
vi.spyOn(bashExecutor, "executeBash").mockImplementation(async (_command, options) => {
bashStarted.resolve();
await finishBash.promise;
artifactId = await options?.onMinimizedSave?.("full old-session output", {
filter: "test",
inputBytes: 23,
outputBytes: 10,
});
return { ...bashResult, output: artifactId ? `[raw output: artifact://${artifactId}]` : "missing artifact" };
});
const bashPromise = session.executeBash("large old-session command");
await bashStarted.promise;
await session.newSession();
finishBash.resolve();
await bashPromise;
expect(artifactId).toBeDefined();
const oldSession = await SessionManager.open(oldSessionFile, sessionDir, undefined, {
initialCwd: tempDir.path(),
suppressBreadcrumb: true,
});
additionalManagers.push(oldSession);
const artifactPath = await oldSession.getArtifactPath(artifactId!);
expect(artifactPath).not.toBeNull();
expect(await Bun.file(artifactPath!).text()).toBe("full old-session output");
expect(await session.sessionManager.getArtifactPath(artifactId!)).toBeNull();
expect(
oldSession
.getBranch()
.some(
entry =>
entry.type === "message" &&
entry.message.role === "bashExecution" &&
entry.message.output.includes(`artifact://${artifactId}`),
),
).toBe(true);
});
it("does not recreate a dropped session for a late bash result or artifact", async () => {
const sessionDir = path.join(tempDir.path(), "sessions");
createSession(SessionManager.create(tempDir.path(), sessionDir));
const oldSessionFile = await seedPersistedSession();
const oldArtifactsDir = oldSessionFile.slice(0, -6);
const bashStarted = Promise.withResolvers<void>();
const finishBash = Promise.withResolvers<void>();
vi.spyOn(bashExecutor, "executeBash").mockImplementation(async (_command, options) => {
bashStarted.resolve();
await finishBash.promise;
const artifactId = await options?.onMinimizedSave?.("discarded raw output", {
filter: "test",
inputBytes: 20,
outputBytes: 9,
});
return { ...bashResult, output: artifactId ? `[raw output: artifact://${artifactId}]` : "discarded" };
});
const bashPromise = session.executeBash("dropped-session-command");
await bashStarted.promise;
await session.newSession({ drop: true });
expect(fs.existsSync(oldSessionFile)).toBe(false);
expect(fs.existsSync(oldArtifactsDir)).toBe(false);
finishBash.resolve();
await bashPromise;
expect(fs.existsSync(oldSessionFile)).toBe(false);
expect(fs.existsSync(oldArtifactsDir)).toBe(false);
expect(
session.messages.some(
message => message.role === "bashExecution" && message.command === "dropped-session-command",
),
).toBe(false);
});
it("keeps a late bash result on the branch where it started", async () => {
const sessionDir = path.join(tempDir.path(), "sessions");
const { completion, extensionRunner } = createGatedBashRunner();
createSession(SessionManager.create(tempDir.path(), sessionDir), extensionRunner);
await session.prompt("first prompt");
await session.waitForIdle();
await session.prompt("second prompt");
await session.waitForIdle();
const firstUserEntry = session.sessionManager
.getEntries()
.find(entry => entry.type === "message" && entry.message.role === "user");
if (!firstUserEntry) throw new Error("Expected first user entry");
const originalLeafId = session.sessionManager.getLeafId();
if (!originalLeafId) throw new Error("Expected original branch leaf");
const bashPromise = session.executeBash("old-branch-command");
await session.navigateTree(firstUserEntry.id);
const navigatedLeafId = firstUserEntry.parentId;
expect(session.sessionManager.getLeafId()).toBe(navigatedLeafId);
completion.resolve({ result: bashResult });
await bashPromise;
const bashEntry = session.sessionManager
.getEntries()
.find(
entry =>
entry.type === "message" &&
entry.message.role === "bashExecution" &&
entry.message.command === "old-branch-command",
);
expect(bashEntry?.parentId).toBe(originalLeafId);
expect(session.sessionManager.getLeafId()).toBe(navigatedLeafId);
expect(
session.messages.some(message => message.role === "bashExecution" && message.command === "old-branch-command"),
).toBe(false);
});
});