995 lines
27 KiB
TypeScript
995 lines
27 KiB
TypeScript
/**
|
|
* SessionManager unit tests.
|
|
*
|
|
* Uses the MockRpcClient + TestableSessionManager pattern (K008) to test
|
|
* session lifecycle, event handling, cost tracking, blocker detection,
|
|
* and cleanup without spawning real SF processes.
|
|
*/
|
|
|
|
import assert from "node:assert/strict";
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, join, resolve } from "node:path";
|
|
import { afterEach, describe, it } from "vitest";
|
|
import { Logger } from "./logger.js";
|
|
import { SessionManager } from "./session-manager.js";
|
|
import type { ManagedSession, PendingBlocker } from "./types.js";
|
|
import { MAX_EVENTS } from "./types.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock RpcClient (duck-typed to match RpcClient interface)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class MockRpcClient {
|
|
started = false;
|
|
stopped = false;
|
|
aborted = false;
|
|
prompted: string[] = [];
|
|
switchedSessions: string[] = [];
|
|
private eventListeners: Array<(event: Record<string, unknown>) => void> = [];
|
|
uiResponses: Array<{ requestId: string; response: Record<string, unknown> }> =
|
|
[];
|
|
|
|
/** Control — set to make start() reject */
|
|
startError: Error | null = null;
|
|
/** Control — set to make init() reject */
|
|
initError: Error | null = null;
|
|
/** Control — override sessionId from init */
|
|
initSessionId = "mock-session-001";
|
|
|
|
cwd: string;
|
|
args: string[];
|
|
|
|
constructor(options?: Record<string, unknown>) {
|
|
this.cwd = (options?.cwd as string) ?? "";
|
|
this.args = (options?.args as string[]) ?? [];
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
if (this.startError) throw this.startError;
|
|
this.started = true;
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
this.stopped = true;
|
|
}
|
|
|
|
async init(): Promise<{ sessionId: string; version: string }> {
|
|
if (this.initError) throw this.initError;
|
|
return { sessionId: this.initSessionId, version: "2.51.0" };
|
|
}
|
|
|
|
onEvent(listener: (event: Record<string, unknown>) => void): () => void {
|
|
this.eventListeners.push(listener);
|
|
return () => {
|
|
const idx = this.eventListeners.indexOf(listener);
|
|
if (idx >= 0) this.eventListeners.splice(idx, 1);
|
|
};
|
|
}
|
|
|
|
async prompt(message: string): Promise<void> {
|
|
this.prompted.push(message);
|
|
if (message === "/sf pause") {
|
|
queueMicrotask(() => {
|
|
this.emitEvent({
|
|
type: "extension_ui_request",
|
|
id: "pause-notice",
|
|
method: "notify",
|
|
message: "Auto-mode paused: daemon reload requested",
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
async abort(): Promise<void> {
|
|
this.aborted = true;
|
|
}
|
|
|
|
sendUIResponse(requestId: string, response: Record<string, unknown>): void {
|
|
this.uiResponses.push({ requestId, response });
|
|
}
|
|
|
|
async getState(): Promise<{ sessionFile: string; sessionId: string }> {
|
|
return {
|
|
sessionFile: `/tmp/${this.initSessionId}.jsonl`,
|
|
sessionId: this.initSessionId,
|
|
};
|
|
}
|
|
|
|
async switchSession(sessionPath: string): Promise<{ cancelled: boolean }> {
|
|
this.switchedSessions.push(sessionPath);
|
|
return { cancelled: false };
|
|
}
|
|
|
|
/** Test helper — emit an event to all listeners */
|
|
emitEvent(event: Record<string, unknown>): void {
|
|
for (const listener of this.eventListeners) {
|
|
listener(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TestableSessionManager — injects mock clients without module mocking (K008)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class TestableSessionManager extends SessionManager {
|
|
lastClient: MockRpcClient | null = null;
|
|
allClients: MockRpcClient[] = [];
|
|
private sessionCounter = 0;
|
|
nextInitError: Error | null = null;
|
|
nextStartError: Error | null = null;
|
|
|
|
protected override createRpcClient(
|
|
_cliPath: string,
|
|
cwd: string,
|
|
args: string[],
|
|
): any {
|
|
this.sessionCounter++;
|
|
const client = new MockRpcClient({ cwd, args });
|
|
client.initSessionId = `mock-session-${String(this.sessionCounter).padStart(3, "0")}`;
|
|
this.lastClient = client;
|
|
this.allClients.push(client);
|
|
return client;
|
|
}
|
|
|
|
override async startSession(options: {
|
|
projectDir: string;
|
|
command?: string;
|
|
model?: string;
|
|
bare?: boolean;
|
|
cliPath?: string;
|
|
}): Promise<string> {
|
|
const { projectDir } = options;
|
|
|
|
if (!projectDir || projectDir.trim() === "") {
|
|
throw new Error("projectDir is required and cannot be empty");
|
|
}
|
|
|
|
const resolvedDir = resolve(projectDir);
|
|
const projectName = basename(resolvedDir);
|
|
|
|
// Check duplicate via getSessionByDir
|
|
const existing = this.getSessionByDir(resolvedDir);
|
|
if (existing) {
|
|
throw new Error(
|
|
`Session already active for ${resolvedDir} (sessionId: ${existing.sessionId}, status: ${existing.status})`,
|
|
);
|
|
}
|
|
|
|
const client = this.createRpcClient("mock-sf", resolvedDir, []);
|
|
if (this.nextStartError) {
|
|
client.startError = this.nextStartError;
|
|
this.nextStartError = null;
|
|
}
|
|
if (this.nextInitError) {
|
|
client.initError = this.nextInitError;
|
|
this.nextInitError = null;
|
|
}
|
|
|
|
// Build session shell
|
|
const session: ManagedSession = {
|
|
sessionId: "",
|
|
projectDir: resolvedDir,
|
|
projectName,
|
|
status: "starting",
|
|
reloadState: "running",
|
|
client: client as any, // duck-typed mock
|
|
events: [],
|
|
pendingBlocker: null,
|
|
cost: {
|
|
totalCost: 0,
|
|
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
},
|
|
startTime: Date.now(),
|
|
startOptions: { ...options, projectDir: resolvedDir },
|
|
};
|
|
|
|
// Insert into internal sessions map
|
|
(this as any).sessions.set(resolvedDir, session);
|
|
|
|
try {
|
|
await client.start();
|
|
|
|
const initResult = await client.init();
|
|
session.sessionId = initResult.sessionId;
|
|
session.status = "running";
|
|
|
|
// Wire event tracking using parent's handleEvent
|
|
session.unsubscribe = client.onEvent((event: Record<string, unknown>) => {
|
|
(this as any).handleEvent(session, event);
|
|
});
|
|
|
|
// Kick off autonomous mode
|
|
const command = options.command ?? "/sf autonomous";
|
|
await client.prompt(command);
|
|
|
|
// Emit lifecycle events (matching parent behavior)
|
|
(this as any).logger.info("session started", {
|
|
sessionId: session.sessionId,
|
|
projectDir: resolvedDir,
|
|
});
|
|
this.emit("session:started", {
|
|
sessionId: session.sessionId,
|
|
projectDir: resolvedDir,
|
|
projectName,
|
|
});
|
|
|
|
return session.sessionId;
|
|
} catch (err) {
|
|
session.status = "error";
|
|
session.error = err instanceof Error ? err.message : String(err);
|
|
try {
|
|
await client.stop();
|
|
} catch {
|
|
/* swallow */
|
|
}
|
|
|
|
(this as any).logger.error("session error", {
|
|
sessionId: session.sessionId,
|
|
projectDir: resolvedDir,
|
|
error: session.error,
|
|
});
|
|
this.emit("session:error", {
|
|
sessionId: session.sessionId,
|
|
projectDir: resolvedDir,
|
|
projectName,
|
|
error: session.error,
|
|
});
|
|
|
|
throw new Error(
|
|
`Failed to start session for ${resolvedDir}: ${session.error}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Logger spy helper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface LogCall {
|
|
level: string;
|
|
msg: string;
|
|
data?: Record<string, unknown>;
|
|
}
|
|
|
|
class SpyLogger {
|
|
calls: LogCall[] = [];
|
|
private tmpDir: string;
|
|
logger: Logger;
|
|
|
|
constructor() {
|
|
this.tmpDir = mkdtempSync(join(tmpdir(), "sm-test-"));
|
|
this.logger = new Logger({
|
|
filePath: join(this.tmpDir, "test.log"),
|
|
level: "debug",
|
|
});
|
|
|
|
// Intercept write calls by wrapping the logger methods
|
|
const original = {
|
|
debug: this.logger.debug.bind(this.logger),
|
|
info: this.logger.info.bind(this.logger),
|
|
warn: this.logger.warn.bind(this.logger),
|
|
error: this.logger.error.bind(this.logger),
|
|
};
|
|
|
|
this.logger.debug = (msg: string, data?: Record<string, unknown>) => {
|
|
this.calls.push({ level: "debug", msg, data });
|
|
original.debug(msg, data);
|
|
};
|
|
this.logger.info = (msg: string, data?: Record<string, unknown>) => {
|
|
this.calls.push({ level: "info", msg, data });
|
|
original.info(msg, data);
|
|
};
|
|
this.logger.warn = (msg: string, data?: Record<string, unknown>) => {
|
|
this.calls.push({ level: "warn", msg, data });
|
|
original.warn(msg, data);
|
|
};
|
|
this.logger.error = (msg: string, data?: Record<string, unknown>) => {
|
|
this.calls.push({ level: "error", msg, data });
|
|
original.error(msg, data);
|
|
};
|
|
}
|
|
|
|
async cleanup(): Promise<void> {
|
|
await this.logger.close();
|
|
try {
|
|
rmSync(this.tmpDir, { recursive: true, force: true });
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
findCalls(level: string, msgSubstring: string): LogCall[] {
|
|
return this.calls.filter(
|
|
(c) => c.level === level && c.msg.includes(msgSubstring),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let allManagers: TestableSessionManager[] = [];
|
|
let allSpyLoggers: SpyLogger[] = [];
|
|
|
|
function createManager(): { manager: TestableSessionManager; spy: SpyLogger } {
|
|
const spy = new SpyLogger();
|
|
const manager = new TestableSessionManager(spy.logger);
|
|
allManagers.push(manager);
|
|
allSpyLoggers.push(spy);
|
|
return { manager, spy };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("SessionManager", () => {
|
|
afterEach(async () => {
|
|
for (const m of allManagers) {
|
|
try {
|
|
await m.cleanup();
|
|
} catch {
|
|
/* swallow */
|
|
}
|
|
}
|
|
allManagers = [];
|
|
for (const s of allSpyLoggers) {
|
|
await s.cleanup();
|
|
}
|
|
allSpyLoggers = [];
|
|
});
|
|
|
|
// ---- Lifecycle: start → running → completed ----
|
|
|
|
it("start → running → completed lifecycle", async () => {
|
|
const { manager, spy } = createManager();
|
|
|
|
const sessionId = await manager.startSession({
|
|
projectDir: "/tmp/test-project",
|
|
});
|
|
assert.ok(sessionId);
|
|
|
|
const session = manager.getSession(sessionId);
|
|
assert.ok(session);
|
|
assert.equal(session.status, "running");
|
|
assert.equal(session.projectName, "test-project");
|
|
|
|
// Simulate terminal notification
|
|
manager.lastClient!.emitEvent({
|
|
type: "extension_ui_request",
|
|
id: "n1",
|
|
method: "notify",
|
|
message: "Auto-mode stopped: completed all tasks",
|
|
});
|
|
|
|
assert.equal(session.status, "completed");
|
|
|
|
// Verify logger calls
|
|
const startedLogs = spy.findCalls("info", "session started");
|
|
assert.equal(startedLogs.length, 1);
|
|
const completedLogs = spy.findCalls("info", "session completed");
|
|
assert.equal(completedLogs.length, 1);
|
|
});
|
|
|
|
it("runtime epoch mismatch restarts child and resumes prior session file", async () => {
|
|
const { manager } = createManager();
|
|
|
|
const sessionId = await manager.startSession({
|
|
projectDir: "/tmp/reload-project",
|
|
});
|
|
const originalClient = manager.lastClient!;
|
|
const restarted = new Promise<void>((resolve) => {
|
|
manager.once("session:restarted", () => resolve());
|
|
});
|
|
|
|
originalClient.emitEvent({
|
|
type: "runtime_heartbeat",
|
|
sessionId,
|
|
sessionFile: "/tmp/reload-session.jsonl",
|
|
unitType: "execute-task",
|
|
unitId: "M001/S01/T01",
|
|
runtimeEpoch: 100,
|
|
sourceEpoch: 200,
|
|
emittedAt: Date.now(),
|
|
});
|
|
|
|
await restarted;
|
|
|
|
const session = manager.getSession("mock-session-002")!;
|
|
assert.ok(session);
|
|
assert.equal(originalClient.stopped, true);
|
|
assert.equal(manager.allClients.length, 2);
|
|
const replacement = manager.allClients[1];
|
|
assert.deepEqual(replacement.switchedSessions, [
|
|
"/tmp/mock-session-001.jsonl",
|
|
]);
|
|
assert.deepEqual(replacement.prompted, ["/sf autonomous"]);
|
|
assert.equal(session.reloadState, "running");
|
|
});
|
|
|
|
// ---- Lifecycle: start → running → blocked → resolve → running → completed ----
|
|
|
|
it("start → blocked → resolve → running → completed lifecycle", async () => {
|
|
const { manager } = createManager();
|
|
|
|
const sessionId = await manager.startSession({
|
|
projectDir: "/tmp/test-project-2",
|
|
});
|
|
const session = manager.getSession(sessionId)!;
|
|
|
|
// Simulate blocking UI request (non-fire-and-forget method)
|
|
manager.lastClient!.emitEvent({
|
|
type: "extension_ui_request",
|
|
id: "blocker-1",
|
|
method: "confirm",
|
|
title: "Merge PR?",
|
|
message: "Should I merge this PR?",
|
|
});
|
|
|
|
assert.equal(session.status, "blocked");
|
|
assert.ok(session.pendingBlocker);
|
|
assert.equal(session.pendingBlocker!.id, "blocker-1");
|
|
assert.equal(session.pendingBlocker!.method, "confirm");
|
|
|
|
// Resolve the blocker
|
|
await manager.resolveBlocker(sessionId, "yes");
|
|
|
|
assert.equal(session.status, "running");
|
|
assert.equal(session.pendingBlocker, null);
|
|
|
|
// Verify UI response was sent
|
|
const client = manager.lastClient!;
|
|
assert.equal(client.uiResponses.length, 1);
|
|
assert.equal(client.uiResponses[0].requestId, "blocker-1");
|
|
|
|
// Complete the session
|
|
manager.lastClient!.emitEvent({
|
|
type: "extension_ui_request",
|
|
id: "n2",
|
|
method: "notify",
|
|
message: "Auto-mode stopped: all done",
|
|
});
|
|
|
|
assert.equal(session.status, "completed");
|
|
});
|
|
|
|
// ---- Lifecycle: start → error (init failure) ----
|
|
|
|
it("start → error when init fails", async () => {
|
|
const { manager, spy } = createManager();
|
|
|
|
manager.nextInitError = new Error("Connection refused");
|
|
|
|
await assert.rejects(
|
|
() => manager.startSession({ projectDir: "/tmp/test-error-project" }),
|
|
(err: Error) => {
|
|
assert.ok(err.message.includes("Connection refused"));
|
|
return true;
|
|
},
|
|
);
|
|
|
|
// Session should still exist in map with error status
|
|
const session = manager.getSessionByDir("/tmp/test-error-project");
|
|
assert.ok(session);
|
|
assert.equal(session.status, "error");
|
|
assert.ok(session.error?.includes("Connection refused"));
|
|
|
|
// Logger should have error call
|
|
const errorLogs = spy.findCalls("error", "session error");
|
|
assert.equal(errorLogs.length, 1);
|
|
});
|
|
|
|
// ---- Duplicate session prevention ----
|
|
|
|
it("rejects duplicate session for same projectDir", async () => {
|
|
const { manager } = createManager();
|
|
|
|
await manager.startSession({ projectDir: "/tmp/dup-test" });
|
|
|
|
await assert.rejects(
|
|
() => manager.startSession({ projectDir: "/tmp/dup-test" }),
|
|
(err: Error) => {
|
|
assert.ok(err.message.includes("Session already active"));
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
// ---- Cancel session ----
|
|
|
|
it("cancels a running session", async () => {
|
|
const { manager, spy } = createManager();
|
|
|
|
const sessionId = await manager.startSession({
|
|
projectDir: "/tmp/cancel-test",
|
|
});
|
|
const session = manager.getSession(sessionId)!;
|
|
const client = manager.lastClient!;
|
|
|
|
await manager.cancelSession(sessionId);
|
|
|
|
assert.equal(session.status, "cancelled");
|
|
assert.ok(client.aborted);
|
|
assert.ok(client.stopped);
|
|
|
|
const cancelLogs = spy.findCalls("info", "session cancelled");
|
|
assert.equal(cancelLogs.length, 1);
|
|
});
|
|
|
|
// ---- Cost accumulation (K004 cumulative-max) ----
|
|
|
|
it("accumulates cost using cumulative-max pattern (K004)", async () => {
|
|
const { manager } = createManager();
|
|
|
|
const sessionId = await manager.startSession({
|
|
projectDir: "/tmp/cost-test",
|
|
});
|
|
const session = manager.getSession(sessionId)!;
|
|
const client = manager.lastClient!;
|
|
|
|
// First cost update
|
|
client.emitEvent({
|
|
type: "cost_update",
|
|
runId: "run-1",
|
|
turnCost: 0.01,
|
|
cumulativeCost: 0.01,
|
|
tokens: { input: 100, output: 50, cacheRead: 20, cacheWrite: 10 },
|
|
});
|
|
|
|
assert.equal(session.cost.totalCost, 0.01);
|
|
assert.equal(session.cost.tokens.input, 100);
|
|
|
|
// Second cost update — cumulative values should increase
|
|
client.emitEvent({
|
|
type: "cost_update",
|
|
runId: "run-1",
|
|
turnCost: 0.02,
|
|
cumulativeCost: 0.03,
|
|
tokens: { input: 250, output: 120, cacheRead: 40, cacheWrite: 20 },
|
|
});
|
|
|
|
assert.equal(session.cost.totalCost, 0.03);
|
|
assert.equal(session.cost.tokens.input, 250);
|
|
assert.equal(session.cost.tokens.output, 120);
|
|
|
|
// Third update with lower values — max should hold
|
|
client.emitEvent({
|
|
type: "cost_update",
|
|
runId: "run-2",
|
|
turnCost: 0.005,
|
|
cumulativeCost: 0.02, // lower than 0.03 — should NOT replace
|
|
tokens: { input: 50, output: 30, cacheRead: 5, cacheWrite: 2 },
|
|
});
|
|
|
|
assert.equal(session.cost.totalCost, 0.03); // max held
|
|
assert.equal(session.cost.tokens.input, 250); // max held
|
|
});
|
|
|
|
// ---- Ring buffer event trimming ----
|
|
|
|
it("trims events when exceeding MAX_EVENTS", async () => {
|
|
const { manager } = createManager();
|
|
|
|
const sessionId = await manager.startSession({
|
|
projectDir: "/tmp/ringbuf-test",
|
|
});
|
|
const session = manager.getSession(sessionId)!;
|
|
const client = manager.lastClient!;
|
|
|
|
// Push MAX_EVENTS + 20 events
|
|
for (let i = 0; i < MAX_EVENTS + 20; i++) {
|
|
client.emitEvent({
|
|
type: "assistant_message",
|
|
id: `msg-${i}`,
|
|
content: `Event ${i}`,
|
|
});
|
|
}
|
|
|
|
assert.equal(session.events.length, MAX_EVENTS);
|
|
// Oldest events should be trimmed — first event should be #20
|
|
const firstEvent = session.events[0] as Record<string, unknown>;
|
|
assert.equal(firstEvent.id, "msg-20");
|
|
});
|
|
|
|
// ---- Blocker detection (non-fire-and-forget extension_ui_request) ----
|
|
|
|
it("detects blocker from non-fire-and-forget extension_ui_request", async () => {
|
|
const { manager, spy } = createManager();
|
|
|
|
const sessionId = await manager.startSession({
|
|
projectDir: "/tmp/blocker-test",
|
|
});
|
|
const session = manager.getSession(sessionId)!;
|
|
|
|
manager.lastClient!.emitEvent({
|
|
type: "extension_ui_request",
|
|
id: "sel-1",
|
|
method: "select",
|
|
title: "Choose deployment target",
|
|
options: ["staging", "production"],
|
|
});
|
|
|
|
assert.equal(session.status, "blocked");
|
|
assert.ok(session.pendingBlocker);
|
|
assert.equal(session.pendingBlocker!.method, "select");
|
|
|
|
const blockedLogs = spy.findCalls("info", "session blocked");
|
|
assert.equal(blockedLogs.length, 1);
|
|
});
|
|
|
|
// ---- Fire-and-forget methods do NOT block ----
|
|
|
|
it("fire-and-forget methods do not trigger blocker", async () => {
|
|
const { manager } = createManager();
|
|
|
|
const sessionId = await manager.startSession({
|
|
projectDir: "/tmp/faf-test",
|
|
});
|
|
const session = manager.getSession(sessionId)!;
|
|
|
|
// setStatus is fire-and-forget
|
|
manager.lastClient!.emitEvent({
|
|
type: "extension_ui_request",
|
|
id: "st-1",
|
|
method: "setStatus",
|
|
statusKey: "build",
|
|
statusText: "Building...",
|
|
});
|
|
|
|
assert.equal(session.status, "running");
|
|
assert.equal(session.pendingBlocker, null);
|
|
});
|
|
|
|
// ---- Terminal detection (auto-mode stopped notification) ----
|
|
|
|
it("detects terminal from auto-mode stopped notification", async () => {
|
|
const { manager } = createManager();
|
|
|
|
const sessionId = await manager.startSession({
|
|
projectDir: "/tmp/terminal-test",
|
|
});
|
|
const session = manager.getSession(sessionId)!;
|
|
|
|
manager.lastClient!.emitEvent({
|
|
type: "extension_ui_request",
|
|
id: "n1",
|
|
method: "notify",
|
|
message: "Step-mode stopped: user requested",
|
|
});
|
|
|
|
assert.equal(session.status, "completed");
|
|
});
|
|
|
|
// ---- getAllSessions returns all tracked sessions ----
|
|
|
|
it("getAllSessions returns all tracked sessions", async () => {
|
|
const { manager } = createManager();
|
|
|
|
await manager.startSession({ projectDir: "/tmp/proj-a" });
|
|
await manager.startSession({ projectDir: "/tmp/proj-b" });
|
|
await manager.startSession({ projectDir: "/tmp/proj-c" });
|
|
|
|
const all = manager.getAllSessions();
|
|
assert.equal(all.length, 3);
|
|
|
|
const dirs = all.map((s) => s.projectDir).sort();
|
|
assert.ok(dirs[0].endsWith("proj-a"));
|
|
assert.ok(dirs[1].endsWith("proj-b"));
|
|
assert.ok(dirs[2].endsWith("proj-c"));
|
|
});
|
|
|
|
// ---- cleanup stops all active sessions ----
|
|
|
|
it("cleanup stops all active sessions", async () => {
|
|
const { manager } = createManager();
|
|
|
|
await manager.startSession({ projectDir: "/tmp/cleanup-a" });
|
|
await manager.startSession({ projectDir: "/tmp/cleanup-b" });
|
|
|
|
const clients = [...manager.allClients];
|
|
assert.equal(clients.length, 2);
|
|
|
|
await manager.cleanup();
|
|
|
|
const all = manager.getAllSessions();
|
|
for (const s of all) {
|
|
assert.equal(s.status, "cancelled");
|
|
}
|
|
// Both clients should have been stopped
|
|
for (const c of clients) {
|
|
assert.ok(c.stopped);
|
|
}
|
|
});
|
|
|
|
// ---- EventEmitter: session:started ----
|
|
|
|
it("emits session:started event", async () => {
|
|
const { manager } = createManager();
|
|
|
|
let emittedData: Record<string, unknown> | undefined;
|
|
manager.on("session:started", (data: Record<string, unknown>) => {
|
|
emittedData = data;
|
|
});
|
|
|
|
const sessionId = await manager.startSession({
|
|
projectDir: "/tmp/emit-start",
|
|
});
|
|
|
|
assert.ok(emittedData);
|
|
assert.equal(emittedData.sessionId, sessionId);
|
|
assert.equal(emittedData.projectName, "emit-start");
|
|
});
|
|
|
|
// ---- EventEmitter: session:blocked ----
|
|
|
|
it("emits session:blocked event", async () => {
|
|
const { manager } = createManager();
|
|
|
|
let emittedData: Record<string, unknown> | undefined;
|
|
manager.on("session:blocked", (data: Record<string, unknown>) => {
|
|
emittedData = data;
|
|
});
|
|
|
|
await manager.startSession({ projectDir: "/tmp/emit-blocked" });
|
|
|
|
manager.lastClient!.emitEvent({
|
|
type: "extension_ui_request",
|
|
id: "b-1",
|
|
method: "input",
|
|
title: "Enter API key",
|
|
});
|
|
|
|
assert.ok(emittedData);
|
|
assert.equal((emittedData.blocker as PendingBlocker).id, "b-1");
|
|
});
|
|
|
|
// ---- EventEmitter: session:completed ----
|
|
|
|
it("emits session:completed event", async () => {
|
|
const { manager } = createManager();
|
|
|
|
let emittedData: Record<string, unknown> | undefined;
|
|
manager.on("session:completed", (data: Record<string, unknown>) => {
|
|
emittedData = data;
|
|
});
|
|
|
|
await manager.startSession({ projectDir: "/tmp/emit-completed" });
|
|
|
|
manager.lastClient!.emitEvent({
|
|
type: "extension_ui_request",
|
|
id: "n1",
|
|
method: "notify",
|
|
message: "Auto-mode stopped: success",
|
|
});
|
|
|
|
assert.ok(emittedData);
|
|
assert.equal(emittedData.projectName, "emit-completed");
|
|
});
|
|
|
|
// ---- EventEmitter: session:error ----
|
|
|
|
it("emits session:error event on init failure", async () => {
|
|
const { manager } = createManager();
|
|
|
|
let emittedData: Record<string, unknown> | undefined;
|
|
manager.on("session:error", (data: Record<string, unknown>) => {
|
|
emittedData = data;
|
|
});
|
|
|
|
manager.nextInitError = new Error("Process crashed");
|
|
|
|
try {
|
|
await manager.startSession({ projectDir: "/tmp/emit-error" });
|
|
} catch {
|
|
/* expected */
|
|
}
|
|
|
|
assert.ok(emittedData);
|
|
assert.ok((emittedData.error as string).includes("Process crashed"));
|
|
});
|
|
|
|
// ---- EventEmitter: session:event ----
|
|
|
|
it("emits session:event for every forwarded event", async () => {
|
|
const { manager } = createManager();
|
|
|
|
const events: Record<string, unknown>[] = [];
|
|
manager.on("session:event", (data) => {
|
|
events.push(data);
|
|
});
|
|
|
|
await manager.startSession({ projectDir: "/tmp/emit-event" });
|
|
|
|
manager.lastClient!.emitEvent({
|
|
type: "assistant_message",
|
|
id: "a1",
|
|
content: "Hello",
|
|
});
|
|
manager.lastClient!.emitEvent({ type: "tool_use", id: "t1", name: "read" });
|
|
|
|
assert.equal(events.length, 2);
|
|
});
|
|
|
|
// ---- Empty projectDir rejection ----
|
|
|
|
it("rejects empty projectDir", async () => {
|
|
const { manager } = createManager();
|
|
|
|
await assert.rejects(
|
|
() => manager.startSession({ projectDir: "" }),
|
|
(err: Error) => {
|
|
assert.ok(err.message.includes("projectDir is required"));
|
|
return true;
|
|
},
|
|
);
|
|
|
|
await assert.rejects(
|
|
() => manager.startSession({ projectDir: " " }),
|
|
(err: Error) => {
|
|
assert.ok(err.message.includes("projectDir is required"));
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
// ---- Logger receives structured calls ----
|
|
|
|
it("logger receives structured calls during lifecycle", async () => {
|
|
const { manager, spy } = createManager();
|
|
|
|
const _sessionId = await manager.startSession({
|
|
projectDir: "/tmp/log-test",
|
|
});
|
|
|
|
// Should have 'session started' info log
|
|
const started = spy.findCalls("info", "session started");
|
|
assert.equal(started.length, 1);
|
|
assert.ok(started[0].data?.sessionId);
|
|
assert.ok(started[0].data?.projectDir);
|
|
|
|
// Emit an event — should produce debug log
|
|
manager.lastClient!.emitEvent({
|
|
type: "assistant_message",
|
|
id: "a1",
|
|
content: "hi",
|
|
});
|
|
const debugLogs = spy.findCalls("debug", "session event");
|
|
assert.ok(debugLogs.length >= 1);
|
|
assert.ok(debugLogs[0].data?.type);
|
|
});
|
|
|
|
// ---- getResult returns structured status ----
|
|
|
|
it("getResult returns structured status", async () => {
|
|
const { manager } = createManager();
|
|
|
|
const sessionId = await manager.startSession({
|
|
projectDir: "/tmp/result-test",
|
|
});
|
|
const result = manager.getResult(sessionId);
|
|
|
|
assert.equal(result.sessionId, sessionId);
|
|
assert.equal(result.status, "running");
|
|
assert.equal(result.reloadState, "running");
|
|
assert.equal(result.projectName, "result-test");
|
|
assert.equal(result.error, null);
|
|
assert.equal(result.lastHeartbeat, null);
|
|
assert.equal(result.pendingBlocker, null);
|
|
assert.ok(typeof result.durationMs === "number");
|
|
assert.ok(result.cost);
|
|
assert.ok(Array.isArray(result.recentEvents));
|
|
});
|
|
|
|
// ---- getResult throws for unknown session ----
|
|
|
|
it("getResult throws for unknown sessionId", () => {
|
|
const { manager } = createManager();
|
|
|
|
assert.throws(
|
|
() => manager.getResult("nonexistent"),
|
|
(err: Error) => err.message.includes("Session not found"),
|
|
);
|
|
});
|
|
|
|
// ---- resolveBlocker throws when no blocker pending ----
|
|
|
|
it("resolveBlocker throws when no blocker pending", async () => {
|
|
const { manager } = createManager();
|
|
|
|
const sessionId = await manager.startSession({
|
|
projectDir: "/tmp/no-blocker",
|
|
});
|
|
|
|
await assert.rejects(
|
|
() => manager.resolveBlocker(sessionId, "yes"),
|
|
(err: Error) => err.message.includes("No pending blocker"),
|
|
);
|
|
});
|
|
|
|
// ---- cancelSession throws for unknown session ----
|
|
|
|
it("cancelSession throws for unknown sessionId", async () => {
|
|
const { manager } = createManager();
|
|
|
|
await assert.rejects(
|
|
() => manager.cancelSession("nonexistent"),
|
|
(err: Error) => err.message.includes("Session not found"),
|
|
);
|
|
});
|
|
|
|
// ---- Blocked notification detected as blocker, not terminal ----
|
|
|
|
it("blocked notification sets status to blocked, not completed", async () => {
|
|
const { manager } = createManager();
|
|
|
|
const sessionId = await manager.startSession({
|
|
projectDir: "/tmp/blocked-notify",
|
|
});
|
|
const session = manager.getSession(sessionId)!;
|
|
|
|
manager.lastClient!.emitEvent({
|
|
type: "extension_ui_request",
|
|
id: "bn-1",
|
|
method: "notify",
|
|
message: "Auto-mode stopped: Blocked: waiting for approval",
|
|
});
|
|
|
|
assert.equal(session.status, "blocked");
|
|
assert.ok(session.pendingBlocker);
|
|
});
|
|
|
|
// ---- projectName is basename of resolved projectDir ----
|
|
|
|
it("projectName is basename of projectDir", async () => {
|
|
const { manager } = createManager();
|
|
|
|
const sessionId = await manager.startSession({
|
|
projectDir: "/home/user/projects/my-app",
|
|
});
|
|
const session = manager.getSession(sessionId)!;
|
|
|
|
assert.equal(session.projectName, "my-app");
|
|
});
|
|
|
|
// ---- Default command starts autonomous mode ----
|
|
|
|
it("sends autonomous command when no command is provided", async () => {
|
|
const { manager } = createManager();
|
|
|
|
await manager.startSession({ projectDir: "/tmp/default-autonomous" });
|
|
const client = manager.lastClient!;
|
|
|
|
assert.ok(client.prompted.includes("/sf autonomous"));
|
|
});
|
|
|
|
// ---- Custom command is sent instead of default ----
|
|
|
|
it("sends custom command when provided", async () => {
|
|
const { manager } = createManager();
|
|
|
|
await manager.startSession({
|
|
projectDir: "/tmp/custom-cmd",
|
|
command: "/sf quick fix-typo",
|
|
});
|
|
const client = manager.lastClient!;
|
|
|
|
assert.ok(client.prompted.includes("/sf quick fix-typo"));
|
|
assert.ok(!client.prompted.includes("/sf autonomous"));
|
|
});
|
|
|
|
// ---- getSessionByDir returns session by directory lookup ----
|
|
|
|
it("getSessionByDir returns session by directory", async () => {
|
|
const { manager } = createManager();
|
|
|
|
await manager.startSession({ projectDir: "/tmp/dir-lookup" });
|
|
const session = manager.getSessionByDir("/tmp/dir-lookup");
|
|
|
|
assert.ok(session);
|
|
assert.equal(session.projectName, "dir-lookup");
|
|
});
|
|
});
|