Archived
Remove test type assertion suppressions
This commit is contained in:
@@ -1,9 +1,8 @@
|
|||||||
/* eslint-disable @typescript-eslint/consistent-type-assertions */
|
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { SessionEventHub } from "./sessionEventHub.js";
|
import { SessionEventHub, type RealtimeSocket } from "./sessionEventHub.js";
|
||||||
|
|
||||||
class FakeSocket extends EventEmitter {
|
class FakeSocket extends EventEmitter implements RealtimeSocket {
|
||||||
readonly OPEN = 1;
|
readonly OPEN = 1;
|
||||||
readyState = this.OPEN;
|
readyState = this.OPEN;
|
||||||
send = vi.fn();
|
send = vi.fn();
|
||||||
@@ -14,8 +13,8 @@ describe("SessionEventHub", () => {
|
|||||||
const hub = new SessionEventHub();
|
const hub = new SessionEventHub();
|
||||||
const sessionSocket = new FakeSocket();
|
const sessionSocket = new FakeSocket();
|
||||||
const otherSocket = new FakeSocket();
|
const otherSocket = new FakeSocket();
|
||||||
hub.add("s1", sessionSocket as never);
|
hub.add("s1", sessionSocket);
|
||||||
hub.add("s2", otherSocket as never);
|
hub.add("s2", otherSocket);
|
||||||
|
|
||||||
hub.publish("s1", { type: "assistant.delta", text: "hello" });
|
hub.publish("s1", { type: "assistant.delta", text: "hello" });
|
||||||
|
|
||||||
@@ -28,8 +27,8 @@ describe("SessionEventHub", () => {
|
|||||||
const closed = new FakeSocket();
|
const closed = new FakeSocket();
|
||||||
const removed = new FakeSocket();
|
const removed = new FakeSocket();
|
||||||
closed.readyState = 3;
|
closed.readyState = 3;
|
||||||
hub.add("s1", closed as never);
|
hub.add("s1", closed);
|
||||||
hub.add("s1", removed as never);
|
hub.add("s1", removed);
|
||||||
removed.emit("close");
|
removed.emit("close");
|
||||||
|
|
||||||
hub.publish("s1", { type: "session.error", message: "boom" });
|
hub.publish("s1", { type: "session.error", message: "boom" });
|
||||||
@@ -42,8 +41,8 @@ describe("SessionEventHub", () => {
|
|||||||
const hub = new SessionEventHub();
|
const hub = new SessionEventHub();
|
||||||
const globalSocket = new FakeSocket();
|
const globalSocket = new FakeSocket();
|
||||||
const sessionSocket = new FakeSocket();
|
const sessionSocket = new FakeSocket();
|
||||||
hub.addGlobal(globalSocket as never);
|
hub.addGlobal(globalSocket);
|
||||||
hub.add("s1", sessionSocket as never);
|
hub.add("s1", sessionSocket);
|
||||||
|
|
||||||
const status = {
|
const status = {
|
||||||
sessionId: "s1",
|
sessionId: "s1",
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes.js";
|
import type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes.js";
|
||||||
import type { WebSocket } from "ws";
|
|
||||||
|
export interface RealtimeSocket {
|
||||||
|
readonly OPEN: number;
|
||||||
|
readyState: number;
|
||||||
|
send(payload: string): void;
|
||||||
|
on(event: "close", listener: () => void): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export class SessionEventHub {
|
export class SessionEventHub {
|
||||||
private readonly socketsBySession = new Map<string, Set<WebSocket>>();
|
private readonly socketsBySession = new Map<string, Set<RealtimeSocket>>();
|
||||||
private readonly globalSockets = new Set<WebSocket>();
|
private readonly globalSockets = new Set<RealtimeSocket>();
|
||||||
|
|
||||||
add(sessionId: string, socket: WebSocket): void {
|
add(sessionId: string, socket: RealtimeSocket): void {
|
||||||
let sockets = this.socketsBySession.get(sessionId);
|
let sockets = this.socketsBySession.get(sessionId);
|
||||||
if (!sockets) {
|
if (!sockets) {
|
||||||
sockets = new Set();
|
sockets = new Set();
|
||||||
@@ -17,7 +23,7 @@ export class SessionEventHub {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
addGlobal(socket: WebSocket): void {
|
addGlobal(socket: RealtimeSocket): void {
|
||||||
this.globalSockets.add(socket);
|
this.globalSockets.add(socket);
|
||||||
socket.on("close", () => this.globalSockets.delete(socket));
|
socket.on("close", () => this.globalSockets.delete(socket));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
/* eslint-disable @typescript-eslint/consistent-type-assertions */
|
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||||
import type { AgentSession, AgentSessionRuntime, CreateAgentSessionRuntimeFactory, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { SessionEventHub } from "../realtime/sessionEventHub.js";
|
|
||||||
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
|
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
|
||||||
import { PiSessionService } from "./piSessionService.js";
|
import { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||||
|
import { PiSessionService, type PiAgentSession, type PiSessionManager, type PiSessionRuntime, type PiSessionServiceDependencies } from "./piSessionService.js";
|
||||||
|
|
||||||
class CapturingSessionEventHub extends SessionEventHub {
|
class CapturingSessionEventHub extends SessionEventHub {
|
||||||
readonly sessionEvents: { sessionId: string; event: SessionUiEvent }[] = [];
|
readonly sessionEvents: { sessionId: string; event: SessionUiEvent }[] = [];
|
||||||
@@ -18,41 +17,60 @@ class CapturingSessionEventHub extends SessionEventHub {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeSessionManager(cwd = "/workspace"): SessionManager {
|
type SessionGateway = NonNullable<PiSessionServiceDependencies["sessionManager"]>;
|
||||||
|
type RuntimeCreator = NonNullable<PiSessionServiceDependencies["createAgentRuntime"]>;
|
||||||
|
|
||||||
|
interface TestSession extends PiAgentSession {
|
||||||
|
sessionName: string | undefined;
|
||||||
|
model: PiAgentSession["model"];
|
||||||
|
isStreaming: boolean;
|
||||||
|
isCompacting: boolean;
|
||||||
|
isBashRunning: boolean;
|
||||||
|
pendingMessageCount: number;
|
||||||
|
getSteeringMessages: () => readonly string[];
|
||||||
|
getFollowUpMessages: () => readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeSessionManager(cwd = "/workspace"): PiSessionManager {
|
||||||
return {
|
return {
|
||||||
getCwd: () => cwd,
|
getCwd: () => cwd,
|
||||||
getBranch: () => [],
|
getBranch: () => [],
|
||||||
} as unknown as SessionManager;
|
getLeafId: () => "leaf-1",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type RuntimeFactoryResult = Awaited<ReturnType<CreateAgentSessionRuntimeFactory>>;
|
function sessionRecord(id: string, cwd = "/workspace") {
|
||||||
|
return { id, path: `/sessions/${id}.jsonl`, cwd, created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" };
|
||||||
function asRuntimeFactoryResult(runtime: AgentSessionRuntime): RuntimeFactoryResult {
|
|
||||||
return runtime as unknown as RuntimeFactoryResult;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeRuntime(sessionId = "session-1") {
|
function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {}) {
|
||||||
const promptCalls: { text: string; options: unknown }[] = [];
|
const promptCalls: { text: string; options: unknown }[] = [];
|
||||||
const calls = { abort: 0, clearQueue: 0, dispose: 0, prompt: promptCalls };
|
const calls = { abort: 0, clearQueue: 0, dispose: 0, prompt: promptCalls };
|
||||||
const session = {
|
const session: TestSession = {
|
||||||
sessionId,
|
sessionId,
|
||||||
sessionFile: `/tmp/${sessionId}.jsonl`,
|
sessionFile: `/tmp/${sessionId}.jsonl`,
|
||||||
messages: [],
|
messages: [],
|
||||||
sessionName: undefined,
|
sessionName: undefined,
|
||||||
model: undefined,
|
model: undefined,
|
||||||
thinkingLevel: undefined,
|
thinkingLevel: "off",
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
isCompacting: false,
|
isCompacting: false,
|
||||||
isBashRunning: false,
|
isBashRunning: false,
|
||||||
pendingMessageCount: 0,
|
pendingMessageCount: 0,
|
||||||
sessionManager: fakeSessionManager(),
|
sessionManager: fakeSessionManager(),
|
||||||
|
modelRegistry: ModelRegistry.create(AuthStorage.inMemory()),
|
||||||
|
scopedModels: [],
|
||||||
|
extensionRunner: { getRegisteredCommands: () => [] },
|
||||||
|
promptTemplates: [],
|
||||||
|
resourceLoader: { getSkills: () => ({ skills: [] }) },
|
||||||
subscribe: () => () => undefined,
|
subscribe: () => () => undefined,
|
||||||
getSessionStats: () => ({ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, cost: 0 }),
|
getSessionStats: () => ({ sessionId, totalMessages: 0, userMessages: 0, assistantMessages: 0, toolCalls: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 }),
|
||||||
getContextUsage: () => undefined,
|
getContextUsage: () => undefined,
|
||||||
prompt: (text: string, options: unknown) => {
|
prompt: (text: string, options: unknown) => {
|
||||||
calls.prompt.push({ text, options });
|
calls.prompt.push({ text, options });
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
|
executeBash: () => Promise.resolve({ output: "", exitCode: 0, cancelled: false, truncated: false }),
|
||||||
abort: () => {
|
abort: () => {
|
||||||
calls.abort += 1;
|
calls.abort += 1;
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
@@ -63,37 +81,64 @@ function fakeRuntime(sessionId = "session-1") {
|
|||||||
},
|
},
|
||||||
getSteeringMessages: () => [],
|
getSteeringMessages: () => [],
|
||||||
getFollowUpMessages: () => [],
|
getFollowUpMessages: () => [],
|
||||||
} as unknown as AgentSession;
|
setModel: () => Promise.resolve(),
|
||||||
const runtime = {
|
cycleModel: () => Promise.resolve(undefined),
|
||||||
|
getAvailableThinkingLevels: () => [],
|
||||||
|
setThinkingLevel: () => undefined,
|
||||||
|
cycleThinkingLevel: () => undefined,
|
||||||
|
setSessionName: (name: string) => { session.sessionName = name; },
|
||||||
|
compact: () => Promise.resolve({ summary: "", tokensBefore: 0 }),
|
||||||
|
getUserMessagesForForking: () => [],
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
const runtime: PiSessionRuntime = {
|
||||||
|
cwd: session.sessionManager.getCwd(),
|
||||||
session,
|
session,
|
||||||
setRebindSession: () => undefined,
|
setRebindSession: () => undefined,
|
||||||
|
fork: () => Promise.resolve({ cancelled: false }),
|
||||||
dispose: () => {
|
dispose: () => {
|
||||||
calls.dispose += 1;
|
calls.dispose += 1;
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
} as unknown as AgentSessionRuntime;
|
};
|
||||||
return { runtime, calls };
|
return { runtime, session, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeCreator(runtime: PiSessionRuntime): RuntimeCreator {
|
||||||
|
return async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
return runtime;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionGateway(records: ReturnType<typeof sessionRecord>[]): SessionGateway {
|
||||||
|
return {
|
||||||
|
create: () => fakeSessionManager(),
|
||||||
|
list: () => Promise.resolve(records),
|
||||||
|
listAll: () => Promise.resolve(records),
|
||||||
|
open: () => fakeSessionManager(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("PiSessionService", () => {
|
describe("PiSessionService", () => {
|
||||||
it("starts sessions through an injected runtime factory", async () => {
|
it("starts sessions through an injected runtime creator", async () => {
|
||||||
const hub = new CapturingSessionEventHub();
|
const hub = new CapturingSessionEventHub();
|
||||||
const fake = fakeRuntime();
|
const fake = fakeRuntime();
|
||||||
const createRuntime: CreateAgentSessionRuntimeFactory = () => Promise.resolve(asRuntimeFactoryResult(fake.runtime));
|
let createCalls = 0;
|
||||||
|
const createAgentRuntime: RuntimeCreator = async () => {
|
||||||
|
createCalls += 1;
|
||||||
|
await Promise.resolve();
|
||||||
|
return fake.runtime;
|
||||||
|
};
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
createRuntime,
|
createAgentRuntime,
|
||||||
createAgentRuntime: () => Promise.resolve(fake.runtime),
|
sessionManager: sessionGateway([]),
|
||||||
sessionManager: {
|
|
||||||
create: () => fakeSessionManager(),
|
|
||||||
list: () => Promise.resolve([]),
|
|
||||||
listAll: () => Promise.resolve([]),
|
|
||||||
open: () => fakeSessionManager(),
|
|
||||||
},
|
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const session = await service.start("/workspace");
|
const session = await service.start("/workspace");
|
||||||
|
|
||||||
|
expect(createCalls).toBe(1);
|
||||||
expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 });
|
expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 });
|
||||||
expect(service.activeCount()).toBe(1);
|
expect(service.activeCount()).toBe(1);
|
||||||
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "session-1")).toBe(true);
|
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "session-1")).toBe(true);
|
||||||
@@ -114,8 +159,8 @@ describe("PiSessionService", () => {
|
|||||||
sessionManager: {
|
sessionManager: {
|
||||||
create: () => fakeSessionManager(),
|
create: () => fakeSessionManager(),
|
||||||
list: () => Promise.resolve([
|
list: () => Promise.resolve([
|
||||||
{ id: "active", path: "/sessions/active.jsonl", cwd: "/workspace", created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello" },
|
{ ...sessionRecord("active"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello" },
|
||||||
{ id: "archived", path: "/sessions/archived.jsonl", cwd: "/workspace", created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 2, firstMessage: "bye", allMessagesText: "bye" },
|
{ ...sessionRecord("archived"), messageCount: 2, firstMessage: "bye", allMessagesText: "bye" },
|
||||||
]),
|
]),
|
||||||
listAll: () => Promise.resolve([]),
|
listAll: () => Promise.resolve([]),
|
||||||
open: () => fakeSessionManager(),
|
open: () => fakeSessionManager(),
|
||||||
@@ -135,14 +180,8 @@ describe("PiSessionService", () => {
|
|||||||
it("sends prompts to an injected runtime without touching the SDK runtime", async () => {
|
it("sends prompts to an injected runtime without touching the SDK runtime", async () => {
|
||||||
const fake = fakeRuntime("prompt-session");
|
const fake = fakeRuntime("prompt-session");
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
createRuntime: () => Promise.resolve(asRuntimeFactoryResult(fake.runtime)),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
createAgentRuntime: () => Promise.resolve(fake.runtime),
|
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
|
||||||
sessionManager: {
|
|
||||||
create: () => fakeSessionManager(),
|
|
||||||
list: () => Promise.resolve([]),
|
|
||||||
listAll: () => Promise.resolve([{ id: "prompt-session", path: "/sessions/prompt-session.jsonl", cwd: "/workspace", created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" }]),
|
|
||||||
open: () => fakeSessionManager(),
|
|
||||||
},
|
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,19 +192,14 @@ describe("PiSessionService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("includes queued message details in session status", async () => {
|
it("includes queued message details in session status", async () => {
|
||||||
const fake = fakeRuntime("status-session");
|
const fake = fakeRuntime("status-session", {
|
||||||
(fake.runtime.session as unknown as { pendingMessageCount: number; getSteeringMessages: () => string[]; getFollowUpMessages: () => string[] }).pendingMessageCount = 2;
|
pendingMessageCount: 2,
|
||||||
(fake.runtime.session as unknown as { getSteeringMessages: () => string[] }).getSteeringMessages = () => ["adjust this turn"];
|
getSteeringMessages: () => ["adjust this turn"],
|
||||||
(fake.runtime.session as unknown as { getFollowUpMessages: () => string[] }).getFollowUpMessages = () => ["then do this"];
|
getFollowUpMessages: () => ["then do this"],
|
||||||
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
createRuntime: () => Promise.resolve(asRuntimeFactoryResult(fake.runtime)),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
createAgentRuntime: () => Promise.resolve(fake.runtime),
|
sessionManager: sessionGateway([sessionRecord("status-session")]),
|
||||||
sessionManager: {
|
|
||||||
create: () => fakeSessionManager(),
|
|
||||||
list: () => Promise.resolve([]),
|
|
||||||
listAll: () => Promise.resolve([{ id: "status-session", path: "/sessions/status-session.jsonl", cwd: "/workspace", created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" }]),
|
|
||||||
open: () => fakeSessionManager(),
|
|
||||||
},
|
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,19 +211,14 @@ describe("PiSessionService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not enqueue duplicate queued message text", async () => {
|
it("does not enqueue duplicate queued message text", async () => {
|
||||||
const fake = fakeRuntime("dedupe-session");
|
const fake = fakeRuntime("dedupe-session", {
|
||||||
(fake.runtime.session as unknown as { isStreaming: boolean; pendingMessageCount: number; getFollowUpMessages: () => string[] }).isStreaming = true;
|
isStreaming: true,
|
||||||
(fake.runtime.session as unknown as { pendingMessageCount: number }).pendingMessageCount = 1;
|
pendingMessageCount: 1,
|
||||||
(fake.runtime.session as unknown as { getFollowUpMessages: () => string[] }).getFollowUpMessages = () => ["already queued"];
|
getFollowUpMessages: () => ["already queued"],
|
||||||
|
});
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
createRuntime: () => Promise.resolve(asRuntimeFactoryResult(fake.runtime)),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
createAgentRuntime: () => Promise.resolve(fake.runtime),
|
sessionManager: sessionGateway([sessionRecord("dedupe-session")]),
|
||||||
sessionManager: {
|
|
||||||
create: () => fakeSessionManager(),
|
|
||||||
list: () => Promise.resolve([]),
|
|
||||||
listAll: () => Promise.resolve([{ id: "dedupe-session", path: "/sessions/dedupe-session.jsonl", cwd: "/workspace", created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" }]),
|
|
||||||
open: () => fakeSessionManager(),
|
|
||||||
},
|
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -201,17 +230,10 @@ describe("PiSessionService", () => {
|
|||||||
|
|
||||||
it("does not append queued prompts to the transcript before delivery", async () => {
|
it("does not append queued prompts to the transcript before delivery", async () => {
|
||||||
const hub = new CapturingSessionEventHub();
|
const hub = new CapturingSessionEventHub();
|
||||||
const fake = fakeRuntime("queued-session");
|
const fake = fakeRuntime("queued-session", { isStreaming: true });
|
||||||
(fake.runtime.session as unknown as { isStreaming: boolean }).isStreaming = true;
|
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
createRuntime: () => Promise.resolve(asRuntimeFactoryResult(fake.runtime)),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
createAgentRuntime: () => Promise.resolve(fake.runtime),
|
sessionManager: sessionGateway([sessionRecord("queued-session")]),
|
||||||
sessionManager: {
|
|
||||||
create: () => fakeSessionManager(),
|
|
||||||
list: () => Promise.resolve([]),
|
|
||||||
listAll: () => Promise.resolve([{ id: "queued-session", path: "/sessions/queued-session.jsonl", cwd: "/workspace", created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" }]),
|
|
||||||
open: () => fakeSessionManager(),
|
|
||||||
},
|
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -225,14 +247,8 @@ describe("PiSessionService", () => {
|
|||||||
it("clears queued messages when aborting active work", async () => {
|
it("clears queued messages when aborting active work", async () => {
|
||||||
const fake = fakeRuntime("abort-session");
|
const fake = fakeRuntime("abort-session");
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
createRuntime: () => Promise.resolve(asRuntimeFactoryResult(fake.runtime)),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
createAgentRuntime: () => Promise.resolve(fake.runtime),
|
sessionManager: sessionGateway([sessionRecord("abort-session")]),
|
||||||
sessionManager: {
|
|
||||||
create: () => fakeSessionManager(),
|
|
||||||
list: () => Promise.resolve([]),
|
|
||||||
listAll: () => Promise.resolve([{ id: "abort-session", path: "/sessions/abort-session.jsonl", cwd: "/workspace", created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" }]),
|
|
||||||
open: () => fakeSessionManager(),
|
|
||||||
},
|
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,61 +262,34 @@ describe("PiSessionService", () => {
|
|||||||
|
|
||||||
it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => {
|
it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => {
|
||||||
const hub = new CapturingSessionEventHub();
|
const hub = new CapturingSessionEventHub();
|
||||||
const fake = fakeRuntime("auth-session");
|
const authStorage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "sk-test" } });
|
||||||
(fake.runtime.session as unknown as { model: { provider: string; id: string } }).model = { provider: "anthropic", id: "claude-3-5-sonnet" };
|
const modelRegistry = ModelRegistry.create(authStorage);
|
||||||
|
const model = modelRegistry.find("anthropic", "claude-3-5-sonnet-20241022");
|
||||||
const credentials = new Map<string, { type: "api_key" | "oauth"; key?: string }>([["anthropic", { type: "api_key", key: "sk-test" }]]);
|
if (model === undefined) throw new Error("Expected Anthropic model fixture");
|
||||||
const authStorage = {
|
const fake = fakeRuntime("auth-session", { model, modelRegistry });
|
||||||
get(provider: string) { return credentials.get(provider); },
|
|
||||||
list(): string[] { return Array.from(credentials.keys()); },
|
|
||||||
getOAuthProviders: () => [],
|
|
||||||
hasAuth(provider: string): boolean { return credentials.has(provider); },
|
|
||||||
getAuthStatus(provider: string) { return credentials.has(provider) ? { configured: true, source: "stored" as const } : { configured: false }; },
|
|
||||||
};
|
|
||||||
let refreshCalls = 0;
|
|
||||||
const knownModels = [{ provider: "anthropic", id: "claude-3-5-sonnet" }];
|
|
||||||
const modelRegistry = {
|
|
||||||
authStorage,
|
|
||||||
refresh(): void { refreshCalls += 1; },
|
|
||||||
getAll: () => knownModels,
|
|
||||||
getAvailable: () => credentials.has("anthropic") ? knownModels : [],
|
|
||||||
find: (provider: string, id: string) => knownModels.find((model) => model.provider === provider && model.id === id),
|
|
||||||
getProviderDisplayName: (provider: string) => provider,
|
|
||||||
getProviderAuthStatus: (provider: string) => authStorage.getAuthStatus(provider),
|
|
||||||
hasConfiguredAuth: (model: { provider: string }) => credentials.has(model.provider),
|
|
||||||
};
|
|
||||||
(fake.runtime.session as unknown as { modelRegistry: typeof modelRegistry }).modelRegistry = modelRegistry;
|
|
||||||
|
|
||||||
const service = new PiSessionService(hub, {
|
const service = new PiSessionService(hub, {
|
||||||
modelRegistry: modelRegistry as unknown as NonNullable<NonNullable<ConstructorParameters<typeof PiSessionService>[1]>["modelRegistry"]>,
|
modelRegistry,
|
||||||
createRuntime: () => Promise.resolve(asRuntimeFactoryResult(fake.runtime)),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
createAgentRuntime: () => Promise.resolve(fake.runtime),
|
sessionManager: sessionGateway([sessionRecord("auth-session")]),
|
||||||
sessionManager: {
|
|
||||||
create: () => fakeSessionManager(),
|
|
||||||
list: () => Promise.resolve([]),
|
|
||||||
listAll: () => Promise.resolve([{ id: "auth-session", path: "/sessions/auth-session.jsonl", cwd: "/workspace", created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" }]),
|
|
||||||
open: () => fakeSessionManager(),
|
|
||||||
},
|
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.status("auth-session");
|
await service.status("auth-session");
|
||||||
hub.sessionEvents.length = 0;
|
hub.sessionEvents.length = 0;
|
||||||
hub.globalEvents.length = 0;
|
hub.globalEvents.length = 0;
|
||||||
const refreshBefore = refreshCalls;
|
|
||||||
|
|
||||||
credentials.delete("anthropic");
|
authStorage.logout("anthropic");
|
||||||
service.applyAuthChange({ removedProviderId: "anthropic" });
|
service.applyAuthChange({ removedProviderId: "anthropic" });
|
||||||
service.applyAuthChange({ removedProviderId: "anthropic" });
|
service.applyAuthChange({ removedProviderId: "anthropic" });
|
||||||
|
|
||||||
const warningCount = () => hub.sessionEvents.filter(({ event }) => event.type === "command.output" && event.level === "error" && event.message.includes("anthropic/claude-3-5-sonnet")).length;
|
const warningCount = () => hub.sessionEvents.filter(({ event }) => event.type === "command.output" && event.level === "error" && event.message.includes("anthropic/claude-3-5-sonnet-20241022")).length;
|
||||||
expect(refreshCalls).toBeGreaterThan(refreshBefore);
|
|
||||||
expect(warningCount()).toBe(1);
|
expect(warningCount()).toBe(1);
|
||||||
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "auth-session")).toBe(true);
|
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "auth-session")).toBe(true);
|
||||||
|
|
||||||
credentials.set("anthropic", { type: "api_key", key: "sk-new" });
|
authStorage.set("anthropic", { type: "api_key", key: "sk-new" });
|
||||||
service.applyAuthChange();
|
service.applyAuthChange();
|
||||||
credentials.delete("anthropic");
|
authStorage.logout("anthropic");
|
||||||
service.applyAuthChange({ removedProviderId: "anthropic" });
|
service.applyAuthChange({ removedProviderId: "anthropic" });
|
||||||
expect(warningCount()).toBe(2);
|
expect(warningCount()).toBe(2);
|
||||||
|
|
||||||
@@ -310,14 +299,8 @@ describe("PiSessionService", () => {
|
|||||||
it("clears queued messages when stopping a session runtime", async () => {
|
it("clears queued messages when stopping a session runtime", async () => {
|
||||||
const fake = fakeRuntime("stop-session");
|
const fake = fakeRuntime("stop-session");
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
createRuntime: () => Promise.resolve(asRuntimeFactoryResult(fake.runtime)),
|
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||||
createAgentRuntime: () => Promise.resolve(fake.runtime),
|
sessionManager: sessionGateway([sessionRecord("stop-session")]),
|
||||||
sessionManager: {
|
|
||||||
create: () => fakeSessionManager(),
|
|
||||||
list: () => Promise.resolve([]),
|
|
||||||
listAll: () => Promise.resolve([{ id: "stop-session", path: "/sessions/stop-session.jsonl", cwd: "/workspace", created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" }]),
|
|
||||||
open: () => fakeSessionManager(),
|
|
||||||
},
|
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { readFile, writeFile } from "node:fs/promises";
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
||||||
import {
|
import {
|
||||||
AuthStorage,
|
AuthStorage,
|
||||||
createAgentSessionFromServices,
|
createAgentSessionFromServices,
|
||||||
@@ -7,7 +8,6 @@ import {
|
|||||||
getAgentDir,
|
getAgentDir,
|
||||||
ModelRegistry,
|
ModelRegistry,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
type AgentSession,
|
|
||||||
type CreateAgentSessionRuntimeFactory,
|
type CreateAgentSessionRuntimeFactory,
|
||||||
} from "@earendil-works/pi-coding-agent";
|
} from "@earendil-works/pi-coding-agent";
|
||||||
import type { ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
|
import type { ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
|
||||||
@@ -29,10 +29,81 @@ function authLossWarningKey(sessionId: string, provider: string, modelId: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "archive" | "restore" | "isArchived">;
|
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "archive" | "restore" | "isArchived">;
|
||||||
type SessionManagerGateway = Pick<typeof SessionManager, "list" | "create" | "listAll" | "open">;
|
type AgentModel = Model<Api>;
|
||||||
type CreateAgentRuntime = typeof createAgentSessionRuntime;
|
type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
|
||||||
|
|
||||||
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ReturnType<typeof ModelRegistry.create>): CreateAgentSessionRuntimeFactory {
|
export interface PiSessionManager {
|
||||||
|
getCwd(): string;
|
||||||
|
getBranch(): unknown[];
|
||||||
|
getLeafId(): string | null;
|
||||||
|
getHeader?(): { parentSession?: string } | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PiSessionManagerGateway {
|
||||||
|
list(cwd: string): Promise<{ id: string; path: string; cwd: string; created: Date; modified: Date; messageCount: number; firstMessage: string; allMessagesText: string; name?: string; parentSessionPath?: string }[]>;
|
||||||
|
create(cwd: string): PiSessionManager;
|
||||||
|
listAll(): Promise<{ id: string; path: string; cwd: string; created: Date; modified: Date; messageCount: number; firstMessage: string; allMessagesText: string }[]>;
|
||||||
|
open(path: string): PiSessionManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PiAgentSession {
|
||||||
|
modelRegistry: ModelRegistryInstance;
|
||||||
|
sessionManager: PiSessionManager;
|
||||||
|
scopedModels: readonly { model: AgentModel; thinkingLevel?: ClientThinkingLevel }[];
|
||||||
|
sessionId: string;
|
||||||
|
sessionFile: string | undefined;
|
||||||
|
sessionName: string | undefined;
|
||||||
|
messages: readonly unknown[];
|
||||||
|
model: AgentModel | undefined;
|
||||||
|
thinkingLevel: ClientThinkingLevel;
|
||||||
|
isStreaming: boolean;
|
||||||
|
isCompacting: boolean;
|
||||||
|
isBashRunning: boolean;
|
||||||
|
pendingMessageCount: number;
|
||||||
|
extensionRunner: { getRegisteredCommands(): readonly { invocationName: string; description?: string }[] };
|
||||||
|
promptTemplates: readonly { name: string; description?: string }[];
|
||||||
|
resourceLoader: { getSkills(): { skills: readonly { name: string; description?: string }[] } };
|
||||||
|
subscribe(listener: (event: unknown) => void): () => void;
|
||||||
|
compact(instructions?: string): Promise<{ summary: string; tokensBefore: number }>;
|
||||||
|
getUserMessagesForForking(): readonly { entryId: string; text: string }[];
|
||||||
|
getSessionStats(): { sessionId: string; totalMessages: number; userMessages: number; assistantMessages: number; toolCalls: number; tokens: ClientSessionStatus["tokens"]; cost: number };
|
||||||
|
getContextUsage(): ClientSessionStatus["contextUsage"] | undefined;
|
||||||
|
prompt(text: string, options?: { streamingBehavior?: "steer" | "followUp" }): Promise<void>;
|
||||||
|
executeBash(command: string, onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }): Promise<{ output: string; exitCode: number | undefined; cancelled: boolean; truncated: boolean; fullOutputPath?: string }>;
|
||||||
|
abort(): Promise<void>;
|
||||||
|
clearQueue(): { steering: string[]; followUp: string[] };
|
||||||
|
getSteeringMessages(): readonly string[];
|
||||||
|
getFollowUpMessages(): readonly string[];
|
||||||
|
setModel(model: AgentModel): Promise<void>;
|
||||||
|
cycleModel(direction?: "forward" | "backward"): Promise<{ model: AgentModel } | undefined>;
|
||||||
|
getAvailableThinkingLevels(): ClientThinkingLevel[];
|
||||||
|
setThinkingLevel(level: ClientThinkingLevel): void;
|
||||||
|
cycleThinkingLevel(): ClientThinkingLevel | undefined;
|
||||||
|
setSessionName(name: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PiSessionRuntime {
|
||||||
|
readonly cwd: string;
|
||||||
|
readonly session: PiAgentSession;
|
||||||
|
setRebindSession(rebindSession?: (session: PiAgentSession) => Promise<void>): void;
|
||||||
|
fork(entryId: string, options?: { position?: "before" | "at" }): Promise<{ cancelled: boolean }>;
|
||||||
|
dispose(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateAgentRuntimeOptions {
|
||||||
|
cwd: string;
|
||||||
|
agentDir: string;
|
||||||
|
sessionManager: PiSessionManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateAgentRuntime = (createRuntime: CreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions) => Promise<PiSessionRuntime>;
|
||||||
|
|
||||||
|
function defaultCreateAgentRuntime(createRuntime: CreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions): Promise<PiSessionRuntime> {
|
||||||
|
if (!(options.sessionManager instanceof SessionManager)) throw new Error("Default runtime creation requires an SDK SessionManager");
|
||||||
|
return createAgentSessionRuntime(createRuntime, { ...options, sessionManager: options.sessionManager });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance): CreateAgentSessionRuntimeFactory {
|
||||||
return async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
|
return async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
|
||||||
const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
|
const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
|
||||||
const options = sessionStartEvent === undefined
|
const options = sessionStartEvent === undefined
|
||||||
@@ -46,25 +117,25 @@ function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: Re
|
|||||||
export interface PiSessionServiceDependencies {
|
export interface PiSessionServiceDependencies {
|
||||||
archiveStore?: SessionArchiveRepository;
|
archiveStore?: SessionArchiveRepository;
|
||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
sessionManager?: SessionManagerGateway;
|
sessionManager?: PiSessionManagerGateway;
|
||||||
createRuntime?: CreateAgentSessionRuntimeFactory;
|
createRuntime?: CreateAgentSessionRuntimeFactory;
|
||||||
createAgentRuntime?: CreateAgentRuntime;
|
createAgentRuntime?: CreateAgentRuntime;
|
||||||
modelRegistry?: ReturnType<typeof ModelRegistry.create>;
|
modelRegistry?: ModelRegistryInstance;
|
||||||
heartbeatIntervalMs?: number;
|
heartbeatIntervalMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PiSessionService {
|
export class PiSessionService {
|
||||||
private readonly active = new Map<string, ActiveSession>();
|
private readonly active = new Map<string, ActiveSession<PiSessionRuntime>>();
|
||||||
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
|
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
|
||||||
private readonly heartbeat: NodeJS.Timeout;
|
private readonly heartbeat: NodeJS.Timeout;
|
||||||
private readonly commandService: SessionCommandService;
|
private readonly commandService: SessionCommandService<PiAgentSession>;
|
||||||
private readonly authLossWarnings = new Set<string>();
|
private readonly authLossWarnings = new Set<string>();
|
||||||
private readonly archiveStore: SessionArchiveRepository;
|
private readonly archiveStore: SessionArchiveRepository;
|
||||||
private readonly agentDir: string;
|
private readonly agentDir: string;
|
||||||
private readonly sessionManager: SessionManagerGateway;
|
private readonly sessionManager: PiSessionManagerGateway;
|
||||||
private readonly createRuntime: CreateAgentSessionRuntimeFactory;
|
private readonly createRuntime: CreateAgentSessionRuntimeFactory;
|
||||||
private readonly createAgentRuntime: CreateAgentRuntime;
|
private readonly createAgentRuntime: CreateAgentRuntime;
|
||||||
private readonly modelRegistry: ReturnType<typeof ModelRegistry.create>;
|
private readonly modelRegistry: ModelRegistryInstance;
|
||||||
|
|
||||||
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) {
|
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) {
|
||||||
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
|
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
|
||||||
@@ -72,7 +143,7 @@ export class PiSessionService {
|
|||||||
this.sessionManager = deps.sessionManager ?? SessionManager;
|
this.sessionManager = deps.sessionManager ?? SessionManager;
|
||||||
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
|
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
|
||||||
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry);
|
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry);
|
||||||
this.createAgentRuntime = deps.createAgentRuntime ?? createAgentSessionRuntime;
|
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
|
||||||
this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
|
this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
|
||||||
this.commandService = new SessionCommandService(
|
this.commandService = new SessionCommandService(
|
||||||
(sessionId) => this.getActive(sessionId),
|
(sessionId) => this.getActive(sessionId),
|
||||||
@@ -217,10 +288,10 @@ export class PiSessionService {
|
|||||||
commands.push({ name: command.invocationName, ...(command.description === undefined ? {} : { description: command.description }), source: "extension" });
|
commands.push({ name: command.invocationName, ...(command.description === undefined ? {} : { description: command.description }), source: "extension" });
|
||||||
}
|
}
|
||||||
for (const template of session.promptTemplates) {
|
for (const template of session.promptTemplates) {
|
||||||
commands.push({ name: template.name, description: template.description, source: "prompt" });
|
commands.push({ name: template.name, ...(template.description === undefined ? {} : { description: template.description }), source: "prompt" });
|
||||||
}
|
}
|
||||||
for (const skill of session.resourceLoader.getSkills().skills) {
|
for (const skill of session.resourceLoader.getSkills().skills) {
|
||||||
commands.push({ name: `skill:${skill.name}`, description: skill.description, source: "skill" });
|
commands.push({ name: `skill:${skill.name}`, ...(skill.description === undefined ? {} : { description: skill.description }), source: "skill" });
|
||||||
}
|
}
|
||||||
return commands.sort((a, b) => a.name.localeCompare(b.name));
|
return commands.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
@@ -332,11 +403,11 @@ export class PiSessionService {
|
|||||||
if (await this.archiveStore.isArchived(sessionId)) throw new Error("Archived sessions are read-only. Restore the session to continue.");
|
if (await this.archiveStore.isArchived(sessionId)) throw new Error("Archived sessions are read-only. Restore the session to continue.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOrOpen(sessionId: string): Promise<AgentSession> {
|
private async getOrOpen(sessionId: string): Promise<PiAgentSession> {
|
||||||
return (await this.getActive(sessionId)).runtime.session;
|
return (await this.getActive(sessionId)).runtime.session;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getActive(sessionId: string): Promise<ActiveSession> {
|
private async getActive(sessionId: string): Promise<ActiveSession<PiSessionRuntime>> {
|
||||||
const active = this.active.get(sessionId);
|
const active = this.active.get(sessionId);
|
||||||
if (active) return active;
|
if (active) return active;
|
||||||
|
|
||||||
@@ -345,9 +416,9 @@ export class PiSessionService {
|
|||||||
return this.create(this.sessionManager.open(match.path), match.cwd);
|
return this.create(this.sessionManager.open(match.path), match.cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async create(sessionManager: SessionManager, cwd: string): Promise<ActiveSession> {
|
private async create(sessionManager: PiSessionManager, cwd: string): Promise<ActiveSession<PiSessionRuntime>> {
|
||||||
const runtime = await this.createAgentRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager });
|
const runtime = await this.createAgentRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager });
|
||||||
const active: ActiveSession = { runtime, unsubscribe: noop };
|
const active: ActiveSession<PiSessionRuntime> = { runtime, unsubscribe: noop };
|
||||||
this.bindRuntime(active);
|
this.bindRuntime(active);
|
||||||
runtime.setRebindSession(() => {
|
runtime.setRebindSession(() => {
|
||||||
this.bindRuntime(active);
|
this.bindRuntime(active);
|
||||||
@@ -358,7 +429,7 @@ export class PiSessionService {
|
|||||||
return active;
|
return active;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bindRuntime(active: ActiveSession): void {
|
private bindRuntime(active: ActiveSession<PiSessionRuntime>): void {
|
||||||
active.unsubscribe();
|
active.unsubscribe();
|
||||||
for (const [sessionId, candidate] of this.active.entries()) {
|
for (const [sessionId, candidate] of this.active.entries()) {
|
||||||
if (candidate === active) this.active.delete(sessionId);
|
if (candidate === active) this.active.delete(sessionId);
|
||||||
@@ -372,7 +443,7 @@ export class PiSessionService {
|
|||||||
this.active.set(session.sessionId, active);
|
this.active.set(session.sessionId, active);
|
||||||
}
|
}
|
||||||
|
|
||||||
private maybeGenerateSessionName(session: AgentSession, firstMessage: string): void {
|
private maybeGenerateSessionName(session: PiAgentSession, firstMessage: string): void {
|
||||||
if (session.sessionName !== undefined || session.messages.length !== 0 || session.isStreaming || session.isCompacting) return;
|
if (session.sessionName !== undefined || session.messages.length !== 0 || session.isStreaming || session.isCompacting) return;
|
||||||
const model = session.model;
|
const model = session.model;
|
||||||
if (model === undefined) return;
|
if (model === undefined) return;
|
||||||
@@ -384,7 +455,7 @@ export class PiSessionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyGeneratedSessionName(session: AgentSession, name: string | undefined): void {
|
private applyGeneratedSessionName(session: PiAgentSession, name: string | undefined): void {
|
||||||
if (name === undefined || session.sessionName !== undefined) return;
|
if (name === undefined || session.sessionName !== undefined) return;
|
||||||
session.setSessionName(name);
|
session.setSessionName(name);
|
||||||
this.publishSessionName(session);
|
this.publishSessionName(session);
|
||||||
@@ -400,7 +471,7 @@ export class PiSessionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncCurrentModelAuthWarning(session: AgentSession, removedProviderId: string | undefined): void {
|
private syncCurrentModelAuthWarning(session: PiAgentSession, removedProviderId: string | undefined): void {
|
||||||
const model = session.model;
|
const model = session.model;
|
||||||
if (model === undefined) return;
|
if (model === undefined) return;
|
||||||
if (model.provider === "unknown" && model.id === "unknown") return;
|
if (model.provider === "unknown" && model.id === "unknown") return;
|
||||||
@@ -427,7 +498,7 @@ export class PiSessionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private publishSessionName(session: AgentSession): void {
|
private publishSessionName(session: PiAgentSession): void {
|
||||||
const event = session.sessionName === undefined
|
const event = session.sessionName === undefined
|
||||||
? { type: "session.name", sessionId: session.sessionId } as const
|
? { type: "session.name", sessionId: session.sessionId } as const
|
||||||
: { type: "session.name", sessionId: session.sessionId, name: session.sessionName } as const;
|
: { type: "session.name", sessionId: session.sessionId, name: session.sessionName } as const;
|
||||||
@@ -447,7 +518,7 @@ export class PiSessionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private activityLabelFromStatus(session: AgentSession): string {
|
private activityLabelFromStatus(session: PiAgentSession): string {
|
||||||
if (session.isCompacting) return "compacting";
|
if (session.isCompacting) return "compacting";
|
||||||
if (session.isBashRunning) return "running bash";
|
if (session.isBashRunning) return "running bash";
|
||||||
if (session.isStreaming) return "agent running";
|
if (session.isStreaming) return "agent running";
|
||||||
@@ -455,7 +526,7 @@ export class PiSessionService {
|
|||||||
return "active";
|
return "active";
|
||||||
}
|
}
|
||||||
|
|
||||||
private publishActivityForEvent(session: AgentSession, event: unknown): void {
|
private publishActivityForEvent(session: PiAgentSession, event: unknown): void {
|
||||||
const eventType = getString(event, "type");
|
const eventType = getString(event, "type");
|
||||||
if (eventType === undefined) return;
|
if (eventType === undefined) return;
|
||||||
if (eventType === "agent_start") { this.publishActivity(session, "agent running", "active"); return; }
|
if (eventType === "agent_start") { this.publishActivity(session, "agent running", "active"); return; }
|
||||||
@@ -482,7 +553,7 @@ export class PiSessionService {
|
|||||||
this.publishActivity(session, eventType.replaceAll("_", " "), "active");
|
this.publishActivity(session, eventType.replaceAll("_", " "), "active");
|
||||||
}
|
}
|
||||||
|
|
||||||
private publishActivity(session: AgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void {
|
private publishActivity(session: PiAgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void {
|
||||||
const at = new Date().toISOString();
|
const at = new Date().toISOString();
|
||||||
const stored = detail === undefined ? { phase, label, at } : { phase, label, detail, at };
|
const stored = detail === undefined ? { phase, label, at } : { phase, label, detail, at };
|
||||||
this.activities.set(session.sessionId, stored);
|
this.activities.set(session.sessionId, stored);
|
||||||
@@ -491,13 +562,13 @@ export class PiSessionService {
|
|||||||
this.events.publishGlobal({ type: "activity.update", activity });
|
this.events.publishGlobal({ type: "activity.update", activity });
|
||||||
}
|
}
|
||||||
|
|
||||||
private publishStatus(session: AgentSession): void {
|
private publishStatus(session: PiAgentSession): void {
|
||||||
const status = this.statusFromSession(session);
|
const status = this.statusFromSession(session);
|
||||||
this.events.publish(session.sessionId, { type: "status.update", status });
|
this.events.publish(session.sessionId, { type: "status.update", status });
|
||||||
this.events.publishGlobal({ type: "status.update", status });
|
this.events.publishGlobal({ type: "status.update", status });
|
||||||
}
|
}
|
||||||
|
|
||||||
private statusFromSession(session: AgentSession): ClientSessionStatus {
|
private statusFromSession(session: PiAgentSession): ClientSessionStatus {
|
||||||
const stats = session.getSessionStats();
|
const stats = session.getSessionStats();
|
||||||
const model = session.model === undefined ? undefined : modelToClientModel(session.model);
|
const model = session.model === undefined ? undefined : modelToClientModel(session.model);
|
||||||
const contextUsage = session.getContextUsage();
|
const contextUsage = session.getContextUsage();
|
||||||
@@ -517,7 +588,7 @@ export class PiSessionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelToClientModel(model: AgentSession["model"]): ClientSessionModel {
|
function modelToClientModel(model: PiAgentSession["model"]): ClientSessionModel {
|
||||||
if (model === undefined) return {};
|
if (model === undefined) return {};
|
||||||
const name = getString(model, "name");
|
const name = getString(model, "name");
|
||||||
const reasoning = getProperty(model, "reasoning");
|
const reasoning = getProperty(model, "reasoning");
|
||||||
@@ -542,15 +613,15 @@ async function clearParentSession(sessionFile: string): Promise<void> {
|
|||||||
await writeFile(sessionFile, `${JSON.stringify(header)}${rest}`, "utf8");
|
await writeFile(sessionFile, `${JSON.stringify(header)}${rest}`, "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSessionQueue(session: AgentSession): void {
|
function clearSessionQueue(session: PiAgentSession): void {
|
||||||
session.clearQueue();
|
session.clearQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasQueuedMessageText(session: AgentSession, text: string): boolean {
|
function hasQueuedMessageText(session: PiAgentSession, text: string): boolean {
|
||||||
return queuedMessagesFromSession(session).some((message) => message.text === text);
|
return queuedMessagesFromSession(session).some((message) => message.text === text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function queuedMessagesFromSession(session: AgentSession): { kind: "steer" | "followUp"; text: string }[] {
|
function queuedMessagesFromSession(session: PiAgentSession): { kind: "steer" | "followUp"; text: string }[] {
|
||||||
return [
|
return [
|
||||||
...session.getSteeringMessages().map((text) => ({ kind: "steer" as const, text })),
|
...session.getSteeringMessages().map((text) => ({ kind: "steer" as const, text })),
|
||||||
...session.getFollowUpMessages().map((text) => ({ kind: "followUp" as const, text })),
|
...session.getFollowUpMessages().map((text) => ({ kind: "followUp" as const, text })),
|
||||||
@@ -561,13 +632,18 @@ function userTextMessage(text: string): { role: "user"; content: string } {
|
|||||||
return { role: "user", content: text };
|
return { role: "user", content: text };
|
||||||
}
|
}
|
||||||
|
|
||||||
function historyMessages(session: AgentSession): unknown[] {
|
function stringValue(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyMessages(session: PiAgentSession): unknown[] {
|
||||||
const messages: unknown[] = [];
|
const messages: unknown[] = [];
|
||||||
for (const entry of session.sessionManager.getBranch()) {
|
for (const entry of session.sessionManager.getBranch()) {
|
||||||
if (entry.type === "message") messages.push(entry.message);
|
if (!isRecord(entry)) continue;
|
||||||
else if (entry.type === "custom_message" && entry.display) messages.push({ role: "custom", content: entry.content, customType: entry.customType, details: entry.details });
|
if (entry["type"] === "message") messages.push(entry["message"]);
|
||||||
else if (entry.type === "compaction") messages.push({ role: "system", source: "compaction", content: `Compacted history:\n\n${entry.summary}` });
|
else if (entry["type"] === "custom_message" && entry["display"] === true) messages.push({ role: "custom", content: entry["content"], customType: entry["customType"], details: entry["details"] });
|
||||||
else if (entry.type === "branch_summary") messages.push({ role: "system", source: "branch_summary", content: `Branch summary:\n\n${entry.summary}` });
|
else if (entry["type"] === "compaction") messages.push({ role: "system", source: "compaction", content: `Compacted history:\n\n${stringValue(entry["summary"])}` });
|
||||||
|
else if (entry["type"] === "branch_summary") messages.push({ role: "system", source: "branch_summary", content: `Branch summary:\n\n${stringValue(entry["summary"])}` });
|
||||||
}
|
}
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
/* eslint-disable @typescript-eslint/consistent-type-assertions */
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { SessionCommandService } from "./sessionCommandService.js";
|
import type { SessionUiEvent } from "../../shared/apiTypes.js";
|
||||||
|
import { SessionCommandService, type CommandActiveSession, type CommandSession } from "./sessionCommandService.js";
|
||||||
|
|
||||||
function activeSession(overrides: Record<string, unknown> = {}) {
|
interface TestCommandSession extends CommandSession {
|
||||||
const session = {
|
sessionName: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeSession(overrides: Partial<TestCommandSession> = {}): CommandActiveSession<TestCommandSession> {
|
||||||
|
const session: TestCommandSession = {
|
||||||
sessionId: "s1",
|
sessionId: "s1",
|
||||||
sessionFile: "/tmp/s1.jsonl",
|
sessionFile: "/tmp/s1.jsonl",
|
||||||
sessionName: undefined as string | undefined,
|
sessionName: undefined,
|
||||||
messages: [{}, {}],
|
messages: [{}, {}],
|
||||||
|
isStreaming: false,
|
||||||
|
isBashRunning: false,
|
||||||
|
isCompacting: false,
|
||||||
|
pendingMessageCount: 0,
|
||||||
promptTemplates: [{ name: "template" }],
|
promptTemplates: [{ name: "template" }],
|
||||||
extensionRunner: { getRegisteredCommands: () => [{ invocationName: "ext" }] },
|
extensionRunner: { getRegisteredCommands: () => [{ invocationName: "ext" }] },
|
||||||
resourceLoader: { getSkills: () => ({ skills: [{ name: "skill-a" }] }) },
|
resourceLoader: { getSkills: () => ({ skills: [{ name: "skill-a" }] }) },
|
||||||
@@ -29,27 +37,27 @@ function activeSession(overrides: Record<string, unknown> = {}) {
|
|||||||
getUserMessagesForForking: vi.fn(() => [{ entryId: "m1", text: "hello ".repeat(40) }]),
|
getUserMessagesForForking: vi.fn(() => [{ entryId: "m1", text: "hello ".repeat(40) }]),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
const runtime = { cwd: "/work", session, fork: vi.fn(async () => {
|
return { runtime: { cwd: "/work", session, fork: vi.fn(() => Promise.resolve({ cancelled: false })) } };
|
||||||
await Promise.resolve();
|
|
||||||
return { cancelled: false };
|
|
||||||
}) };
|
|
||||||
return { runtime };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getActive(active: ReturnType<typeof activeSession>): Promise<never> {
|
async function getActive(active: CommandActiveSession<TestCommandSession>): Promise<CommandActiveSession> {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
return active as never;
|
return active;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function promptAccepted(): Promise<void> {
|
async function promptAccepted(): Promise<void> {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function eventPublisher() {
|
||||||
|
return { publish: vi.fn<(sessionId: string, event: SessionUiEvent) => void>() };
|
||||||
|
}
|
||||||
|
|
||||||
describe("SessionCommandService", () => {
|
describe("SessionCommandService", () => {
|
||||||
it("rejects unknown commands and forwards runtime commands as prompts", async () => {
|
it("rejects unknown commands and forwards runtime commands as prompts", async () => {
|
||||||
const active = activeSession();
|
const active = activeSession();
|
||||||
const prompt = vi.fn(promptAccepted);
|
const prompt = vi.fn(promptAccepted);
|
||||||
const service = new SessionCommandService(() => getActive(active), prompt, { publish: vi.fn() } as never);
|
const service = new SessionCommandService(() => getActive(active), prompt, eventPublisher());
|
||||||
|
|
||||||
await expect(service.run("s1", "/missing")).resolves.toEqual({ type: "unsupported", message: "Unknown command: /missing" });
|
await expect(service.run("s1", "/missing")).resolves.toEqual({ type: "unsupported", message: "Unknown command: /missing" });
|
||||||
await expect(service.run("s1", "/ext arg")).resolves.toEqual({ type: "done", message: "Accepted /ext arg" });
|
await expect(service.run("s1", "/ext arg")).resolves.toEqual({ type: "done", message: "Accepted /ext arg" });
|
||||||
@@ -60,7 +68,7 @@ describe("SessionCommandService", () => {
|
|||||||
|
|
||||||
it("renames sessions and returns updated client session metadata", async () => {
|
it("renames sessions and returns updated client session metadata", async () => {
|
||||||
const active = activeSession();
|
const active = activeSession();
|
||||||
const service = new SessionCommandService(() => getActive(active), vi.fn(), { publish: vi.fn() } as never);
|
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher());
|
||||||
|
|
||||||
await expect(service.run("s1", "/name Useful name")).resolves.toMatchObject({
|
await expect(service.run("s1", "/name Useful name")).resolves.toMatchObject({
|
||||||
type: "done",
|
type: "done",
|
||||||
@@ -72,7 +80,7 @@ describe("SessionCommandService", () => {
|
|||||||
|
|
||||||
it("formats session stats", async () => {
|
it("formats session stats", async () => {
|
||||||
const active = activeSession();
|
const active = activeSession();
|
||||||
const service = new SessionCommandService(() => getActive(active), vi.fn(), { publish: vi.fn() } as never);
|
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher());
|
||||||
|
|
||||||
await expect(service.run("s1", "/session")).resolves.toEqual({
|
await expect(service.run("s1", "/session")).resolves.toEqual({
|
||||||
type: "done",
|
type: "done",
|
||||||
@@ -82,8 +90,8 @@ describe("SessionCommandService", () => {
|
|||||||
|
|
||||||
it("starts compaction and publishes completion", async () => {
|
it("starts compaction and publishes completion", async () => {
|
||||||
const active = activeSession();
|
const active = activeSession();
|
||||||
const events = { publish: vi.fn() };
|
const events = eventPublisher();
|
||||||
const service = new SessionCommandService(() => getActive(active), vi.fn(), events as never);
|
const service = new SessionCommandService(() => getActive(active), vi.fn(), events);
|
||||||
|
|
||||||
await expect(service.run("s1", "/compact focus on tests")).resolves.toEqual({ type: "done", message: "Compaction started…" });
|
await expect(service.run("s1", "/compact focus on tests")).resolves.toEqual({ type: "done", message: "Compaction started…" });
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
@@ -104,7 +112,7 @@ describe("SessionCommandService", () => {
|
|||||||
{ entryId: "newest", text: "newest message" },
|
{ entryId: "newest", text: "newest message" },
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
const service = new SessionCommandService(() => getActive(active), vi.fn(), { publish: vi.fn() } as never);
|
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher());
|
||||||
|
|
||||||
const result = await service.run("s1", "/fork");
|
const result = await service.run("s1", "/fork");
|
||||||
|
|
||||||
@@ -117,7 +125,7 @@ describe("SessionCommandService", () => {
|
|||||||
|
|
||||||
it("rejects fork and clone while the session has active work", async () => {
|
it("rejects fork and clone while the session has active work", async () => {
|
||||||
const active = activeSession({ isStreaming: true });
|
const active = activeSession({ isStreaming: true });
|
||||||
const service = new SessionCommandService(() => getActive(active), vi.fn(), { publish: vi.fn() } as never);
|
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher());
|
||||||
|
|
||||||
await expect(service.run("s1", "/fork")).resolves.toEqual({
|
await expect(service.run("s1", "/fork")).resolves.toEqual({
|
||||||
type: "unsupported",
|
type: "unsupported",
|
||||||
@@ -132,11 +140,11 @@ describe("SessionCommandService", () => {
|
|||||||
|
|
||||||
it("rejects fork responses if the session becomes active after choosing fork", async () => {
|
it("rejects fork responses if the session becomes active after choosing fork", async () => {
|
||||||
const active = activeSession();
|
const active = activeSession();
|
||||||
const service = new SessionCommandService(() => getActive(active), vi.fn(), { publish: vi.fn() } as never);
|
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher());
|
||||||
|
|
||||||
const result = await service.run("s1", "/fork");
|
const result = await service.run("s1", "/fork");
|
||||||
if (result.type !== "select") throw new Error("Expected select result");
|
if (result.type !== "select") throw new Error("Expected select result");
|
||||||
(active.runtime.session as Record<string, unknown>)["isStreaming"] = true;
|
active.runtime.session.isStreaming = true;
|
||||||
|
|
||||||
await expect(service.respond("s1", result.requestId, "m1")).resolves.toEqual({
|
await expect(service.respond("s1", result.requestId, "m1")).resolves.toEqual({
|
||||||
type: "unsupported",
|
type: "unsupported",
|
||||||
|
|||||||
@@ -1,25 +1,66 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import type { AgentSession, AgentSessionRuntime } from "@earendil-works/pi-coding-agent";
|
import type { SessionUiEvent } from "../../shared/apiTypes.js";
|
||||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
|
||||||
import type { ClientCommandResult, ClientSession } from "../types.js";
|
import type { ClientCommandResult, ClientSession } from "../types.js";
|
||||||
import { isBuiltinCommand } from "./builtinCommands.js";
|
import { isBuiltinCommand } from "./builtinCommands.js";
|
||||||
import type { ActiveSession, GetActiveSession } from "./sessionRuntimeStore.js";
|
|
||||||
|
export interface CommandSession {
|
||||||
|
sessionId: string;
|
||||||
|
sessionFile: string | undefined;
|
||||||
|
sessionName: string | undefined;
|
||||||
|
messages: readonly unknown[];
|
||||||
|
isStreaming: boolean;
|
||||||
|
isBashRunning: boolean;
|
||||||
|
isCompacting: boolean;
|
||||||
|
pendingMessageCount: number;
|
||||||
|
promptTemplates: readonly { name: string }[];
|
||||||
|
extensionRunner: { getRegisteredCommands(): readonly { invocationName: string }[] };
|
||||||
|
resourceLoader: { getSkills(): { skills: readonly { name: string }[] } };
|
||||||
|
sessionManager: { getLeafId(): string | null; getHeader?: () => { parentSession?: string } | null | undefined };
|
||||||
|
setSessionName: (name: string) => void;
|
||||||
|
compact: (instructions?: string) => Promise<{ summary: string; tokensBefore: number }>;
|
||||||
|
getSessionStats: () => {
|
||||||
|
sessionId: string;
|
||||||
|
totalMessages: number;
|
||||||
|
userMessages: number;
|
||||||
|
assistantMessages: number;
|
||||||
|
toolCalls: number;
|
||||||
|
tokens: { input: number; output: number; total: number };
|
||||||
|
cost: number;
|
||||||
|
};
|
||||||
|
getUserMessagesForForking: () => readonly { entryId: string; text: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandRuntime<TSession extends CommandSession = CommandSession> {
|
||||||
|
cwd: string;
|
||||||
|
session: TSession;
|
||||||
|
fork: (entryId: string, options?: { position?: "before" | "at" }) => Promise<{ cancelled: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandActiveSession<TSession extends CommandSession = CommandSession> {
|
||||||
|
runtime: CommandRuntime<TSession>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetCommandActiveSession<TSession extends CommandSession = CommandSession> = (sessionId: string) => Promise<CommandActiveSession<TSession>>;
|
||||||
|
|
||||||
|
export interface CommandEventPublisher {
|
||||||
|
publish(sessionId: string, event: SessionUiEvent): void;
|
||||||
|
}
|
||||||
|
|
||||||
interface PendingCommandSelect {
|
interface PendingCommandSelect {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
command: "fork";
|
command: "fork";
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SessionCommandService {
|
export class SessionCommandService<TSession extends CommandSession = CommandSession> {
|
||||||
private readonly pendingSelects = new Map<string, PendingCommandSelect>();
|
private readonly pendingSelects = new Map<string, PendingCommandSelect>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly getActive: GetActiveSession,
|
private readonly getActive: GetCommandActiveSession<TSession>,
|
||||||
private readonly prompt: (sessionId: string, text: string) => Promise<void>,
|
private readonly prompt: (sessionId: string, text: string) => Promise<void>,
|
||||||
private readonly events: SessionEventHub,
|
private readonly events: CommandEventPublisher,
|
||||||
private readonly lifecycle: {
|
private readonly lifecycle: {
|
||||||
onCompactionStart?: (session: AgentSession) => void;
|
onCompactionStart?: (session: TSession) => void;
|
||||||
onCompactionEnd?: (session: AgentSession, result: "success" | "error", detail?: string) => void;
|
onCompactionEnd?: (session: TSession, result: "success" | "error", detail?: string) => void;
|
||||||
} = {},
|
} = {},
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -58,13 +99,13 @@ export class SessionCommandService {
|
|||||||
return { type: "done", message: "Session forked", session: clientSessionFromRuntime(active.runtime) };
|
return { type: "done", message: "Session forked", session: clientSessionFromRuntime(active.runtime) };
|
||||||
}
|
}
|
||||||
|
|
||||||
private nameSession(active: ActiveSession, name: string): ClientCommandResult {
|
private nameSession(active: CommandActiveSession<TSession>, name: string): ClientCommandResult {
|
||||||
if (name === "") return { type: "unsupported", message: "Usage: /name <session name>" };
|
if (name === "") return { type: "unsupported", message: "Usage: /name <session name>" };
|
||||||
active.runtime.session.setSessionName(name);
|
active.runtime.session.setSessionName(name);
|
||||||
return { type: "done", message: `Session named: ${name}`, session: clientSessionFromRuntime(active.runtime) };
|
return { type: "done", message: `Session named: ${name}`, session: clientSessionFromRuntime(active.runtime) };
|
||||||
}
|
}
|
||||||
|
|
||||||
private compact(session: AgentSession, instructions: string): ClientCommandResult {
|
private compact(session: TSession, instructions: string): ClientCommandResult {
|
||||||
this.lifecycle.onCompactionStart?.(session);
|
this.lifecycle.onCompactionStart?.(session);
|
||||||
void session.compact(instructions === "" ? undefined : instructions)
|
void session.compact(instructions === "" ? undefined : instructions)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -84,7 +125,7 @@ export class SessionCommandService {
|
|||||||
return { type: "done", message: "Compaction started…" };
|
return { type: "done", message: "Compaction started…" };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async clone(active: ActiveSession): Promise<ClientCommandResult> {
|
private async clone(active: CommandActiveSession<TSession>): Promise<ClientCommandResult> {
|
||||||
if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("clone");
|
if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("clone");
|
||||||
const leafId = active.runtime.session.sessionManager.getLeafId();
|
const leafId = active.runtime.session.sessionManager.getLeafId();
|
||||||
if (leafId === null || leafId === "") return { type: "unsupported", message: "Cannot clone: no current session entry" };
|
if (leafId === null || leafId === "") return { type: "unsupported", message: "Cannot clone: no current session entry" };
|
||||||
@@ -93,7 +134,7 @@ export class SessionCommandService {
|
|||||||
return { type: "done", message: "Session cloned", session: clientSessionFromRuntime(active.runtime) };
|
return { type: "done", message: "Session cloned", session: clientSessionFromRuntime(active.runtime) };
|
||||||
}
|
}
|
||||||
|
|
||||||
private fork(active: ActiveSession): ClientCommandResult {
|
private fork(active: CommandActiveSession<TSession>): ClientCommandResult {
|
||||||
if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("fork");
|
if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("fork");
|
||||||
const messages = active.runtime.session.getUserMessagesForForking();
|
const messages = active.runtime.session.getUserMessagesForForking();
|
||||||
if (!messages.length) return { type: "unsupported", message: "No user messages to fork from" };
|
if (!messages.length) return { type: "unsupported", message: "No user messages to fork from" };
|
||||||
@@ -107,14 +148,14 @@ export class SessionCommandService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private isRuntimeCommand(session: AgentSession, name: string): boolean {
|
private isRuntimeCommand(session: TSession, name: string): boolean {
|
||||||
return session.extensionRunner.getRegisteredCommands().some((command) => command.invocationName === name)
|
return session.extensionRunner.getRegisteredCommands().some((command) => command.invocationName === name)
|
||||||
|| session.promptTemplates.some((template) => template.name === name)
|
|| session.promptTemplates.some((template) => template.name === name)
|
||||||
|| session.resourceLoader.getSkills().skills.some((skill) => `skill:${skill.name}` === name);
|
|| session.resourceLoader.getSkills().skills.some((skill) => `skill:${skill.name}` === name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clientSessionFromRuntime(runtime: AgentSessionRuntime): ClientSession {
|
function clientSessionFromRuntime(runtime: CommandRuntime): ClientSession {
|
||||||
const session = runtime.session;
|
const session = runtime.session;
|
||||||
const parentSessionPath = typeof session.sessionManager.getHeader === "function" ? session.sessionManager.getHeader()?.parentSession : undefined;
|
const parentSessionPath = typeof session.sessionManager.getHeader === "function" ? session.sessionManager.getHeader()?.parentSession : undefined;
|
||||||
return {
|
return {
|
||||||
@@ -130,7 +171,7 @@ function clientSessionFromRuntime(runtime: AgentSessionRuntime): ClientSession {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionHasActiveWork(session: AgentSession): boolean {
|
function sessionHasActiveWork(session: CommandSession): boolean {
|
||||||
return session.isStreaming || session.isBashRunning || session.isCompacting || session.pendingMessageCount > 0;
|
return session.isStreaming || session.isBashRunning || session.isCompacting || session.pendingMessageCount > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +179,7 @@ function forkActiveUnsupported(command: "fork" | "clone"): ClientCommandResult {
|
|||||||
return { type: "unsupported", message: `Cannot ${command} while the session is active. Stop current activity before ${command === "fork" ? "forking" : "cloning"}.` };
|
return { type: "unsupported", message: `Cannot ${command} while the session is active. Stop current activity before ${command === "fork" ? "forking" : "cloning"}.` };
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSessionStats(session: AgentSession): string {
|
function formatSessionStats(session: CommandSession): string {
|
||||||
const stats = session.getSessionStats();
|
const stats = session.getSessionStats();
|
||||||
return [
|
return [
|
||||||
`Session: ${stats.sessionId}`,
|
`Session: ${stats.sessionId}`,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { AgentSessionRuntime } from "@earendil-works/pi-coding-agent";
|
export interface ActiveSession<TRuntime> {
|
||||||
|
runtime: TRuntime;
|
||||||
export interface ActiveSession {
|
|
||||||
runtime: AgentSessionRuntime;
|
|
||||||
unsubscribe: () => void;
|
unsubscribe: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetActiveSession = (sessionId: string) => Promise<ActiveSession>;
|
export type GetActiveSession<TRuntime> = (sessionId: string) => Promise<ActiveSession<TRuntime>>;
|
||||||
|
|||||||
Reference in New Issue
Block a user