mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Session workers now host a durable Harness over a per-session sqlite storage. The server keeps each session in <sessionDir>/<id>/ with meta.json and session.sqlite and never opens the storage itself. Workers hold retirement while the Harness task graph has live tasks and resume interrupted work on open. Transcript serves the root conversation's durable view directly, Models follows the conversation's agent document, and AgentController maps prompt, steer, follow-up, queue cancellation, abort, and compaction onto the root conversation, plus waitForPrompt. navigate, nextRun, and resume are dropped (see experimental/services/README.md TODO). The durable TUI and the worker share harness-setup.ts. pi-server exports its own SessionMetadata and no longer depends on pi-agent-core. Chord accepts readonly JSON types in service contracts without walking them, which recursive JSON types like pi-ai's made too deep.
356 lines
14 KiB
TypeScript
356 lines
14 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { resolve } from "node:path";
|
|
import {
|
|
createRemoteServiceBinding,
|
|
type MutableReplicatedState,
|
|
RemoteServiceProvider,
|
|
type RemoteServiceTransport,
|
|
replicatedState,
|
|
} from "@earendil-works/chord";
|
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
import {
|
|
FACET_BUNDLE_ARTIFACT_FORMAT,
|
|
FACET_BUNDLE_ARTIFACT_FORMAT_VERSION,
|
|
type FacetBundleArtifact,
|
|
} from "@earendil-works/chord/node";
|
|
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
|
|
import { ProcessTerminal, TuiMainScreen } from "@earendil-works/pi-tui";
|
|
import { beforeAll, describe, expect, test, vi } from "vitest";
|
|
import { type ClientTuiServer, ExperimentalClientTui } from "../src/experimental/client-tui.ts";
|
|
import { createPresentationFacetData } from "../src/experimental/plugins/bundled.ts";
|
|
import { AgentController } from "../src/experimental/services/agent-controller.ts";
|
|
import { createAgentController } from "../src/experimental/services/agent-controller-provider.ts";
|
|
import type {
|
|
ServerConnectionState,
|
|
ServerServiceSource,
|
|
SessionAttachmentState,
|
|
SessionServiceSource,
|
|
} from "../src/experimental/services/connection.ts";
|
|
import { Models, type ModelsState } from "../src/experimental/services/models.ts";
|
|
import { PresentationPlugins, SessionPlugins } from "../src/experimental/services/plugins.ts";
|
|
import {
|
|
SessionDirectory,
|
|
type SessionDirectoryState,
|
|
SessionManagement,
|
|
type SessionSummary,
|
|
} from "../src/experimental/services/sessions.ts";
|
|
import { Transcript } from "../src/experimental/services/transcript.ts";
|
|
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
|
|
import { openFauxConversation } from "./experimental-durable-support.ts";
|
|
|
|
const serverId = "00000000-0000-4000-8000-000000000001";
|
|
|
|
function session(sessionId: string, createdAt: number): SessionSummary {
|
|
return { serverId, sessionId, createdAt };
|
|
}
|
|
|
|
function createLoopbackServiceTransport(provider: RemoteServiceProvider): RemoteServiceTransport {
|
|
return {
|
|
invoke: (call, context) => provider.invoke(call, context),
|
|
subscribe: async (serviceId, mode, listener) => {
|
|
const subscription = provider.subscribe(serviceId, mode, (update) => listener(update, BACKGROUND_CONTEXT));
|
|
return {
|
|
snapshot: subscription.snapshot,
|
|
activate: () => subscription.activate(),
|
|
close: () => subscription.close(),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
function publishReplacement<T extends object>(state: MutableReplicatedState<T>, value: T): void {
|
|
state.replace(BACKGROUND_CONTEXT, value);
|
|
}
|
|
|
|
describe("experimental client TUI", () => {
|
|
beforeAll(() => initTheme("dark"));
|
|
|
|
test.each([
|
|
["new", { command: "client" as const }, "two", 1],
|
|
["continued", { command: "client" as const, continue: true }, "one", 0],
|
|
["plugin-selected", { command: "client" as const, pluginPackages: ["./example-plugin"] }, "two", 1],
|
|
] as const)(
|
|
"opens a %s Session directly and exercises the full lifecycle only for a new Session",
|
|
async (kind, command, sessionId, creates) => {
|
|
const directoryState = replicatedState<SessionDirectoryState>({ revision: 1, sessions: [session("one", 1)] });
|
|
const attachment = replicatedState<SessionAttachmentState>({ status: "detached" });
|
|
const connectionState = replicatedState<ServerConnectionState>({ status: "connected", since: "now" });
|
|
const modelsState = replicatedState<ModelsState>({
|
|
catalog: {
|
|
revision: 1,
|
|
availableModels: [
|
|
{ provider: "test", modelId: "one", name: "Model One", reasoning: false },
|
|
{ provider: "test", modelId: "two", name: "Model Two", reasoning: true },
|
|
],
|
|
},
|
|
configuration: { model: { provider: "test", modelId: "one" }, thinkingLevel: "off" },
|
|
refresh: { status: "idle" },
|
|
});
|
|
const create = vi.fn(async () => {
|
|
const created = session("two", 2);
|
|
directoryState.change(BACKGROUND_CONTEXT, (draft) => {
|
|
draft.revision = 2;
|
|
draft.sessions.push(created);
|
|
});
|
|
return created;
|
|
});
|
|
const select = vi.fn(async (model: { provider: string; modelId: string }) => {
|
|
modelsState.change(BACKGROUND_CONTEXT, (draft) => {
|
|
draft.configuration.model = model;
|
|
});
|
|
});
|
|
const selectThinking = vi.fn(async (thinkingLevel: "off" | "high") => {
|
|
modelsState.change(BACKGROUND_CONTEXT, (draft) => {
|
|
draft.configuration.thinkingLevel = thinkingLevel;
|
|
});
|
|
});
|
|
let finishPrompt!: () => void;
|
|
const promptFinished = new Promise<void>((resolve) => {
|
|
finishPrompt = resolve;
|
|
});
|
|
const prompts: string[] = [];
|
|
const durable = await openFauxConversation([
|
|
async (context) => {
|
|
const last = context.messages.at(-1);
|
|
if (last?.role === "user") {
|
|
prompts.push(typeof last.content === "string" ? last.content : JSON.stringify(last.content));
|
|
}
|
|
await promptFinished;
|
|
return fauxAssistantMessage("remote answer");
|
|
},
|
|
]);
|
|
const transcriptState = await durable.conversation.viewState(BACKGROUND_CONTEXT);
|
|
|
|
const reloadSource =
|
|
'"use strict";\nconst { defineFacet, defineService } = require("@earendil-works/chord");\nconst Models = defineService("pi.models");\nmodule.exports = { __esModule: true, default: defineFacet({ id: "test-tui-facet", setup(env) { env.use(Models); } }) };\n';
|
|
const reloadArtifact: FacetBundleArtifact = {
|
|
format: FACET_BUNDLE_ARTIFACT_FORMAT,
|
|
formatVersion: FACET_BUNDLE_ARTIFACT_FORMAT_VERSION,
|
|
plugin: { id: "test-tui-plugin" },
|
|
entryName: "tui",
|
|
entry: {
|
|
file: "tui.cjs",
|
|
integrity: `sha256-${createHash("sha256").update(reloadSource).digest("base64")}`,
|
|
externalImports: ["@earendil-works/chord"],
|
|
},
|
|
source: reloadSource,
|
|
};
|
|
const reloadData = createPresentationFacetData([reloadArtifact]);
|
|
const prepareSessionPlugins = vi.fn(async () => reloadData);
|
|
const reloadPresentationPlugins = vi.fn(async () => reloadData);
|
|
const reloadSessionPlugins = vi.fn(async () => {});
|
|
const serverProvider = new RemoteServiceProvider([SessionDirectory, SessionManagement, PresentationPlugins]);
|
|
serverProvider.provide(SessionDirectory, { state: directoryState });
|
|
serverProvider.provide(PresentationPlugins, {
|
|
prepareSession: prepareSessionPlugins,
|
|
reload: reloadPresentationPlugins,
|
|
});
|
|
serverProvider.provide(SessionManagement, {
|
|
create,
|
|
async remove() {},
|
|
async attach(sessionId) {
|
|
publishReplacement(attachment, { status: "attaching", sessionId });
|
|
},
|
|
async detach() {
|
|
publishReplacement(attachment, { status: "detached" });
|
|
},
|
|
});
|
|
const sessionProvider = new RemoteServiceProvider([Models, AgentController, SessionPlugins, Transcript]);
|
|
sessionProvider.provide(SessionPlugins, { reload: reloadSessionPlugins });
|
|
sessionProvider.provide(Models, {
|
|
state: modelsState,
|
|
async cycleThinking() {},
|
|
async getThinkingLevels() {
|
|
return ["off", "high"];
|
|
},
|
|
async refresh() {},
|
|
select,
|
|
selectThinking,
|
|
});
|
|
sessionProvider.provide(AgentController, createAgentController(durable.harness, durable.conversation));
|
|
sessionProvider.provide(Transcript, { state: transcriptState });
|
|
|
|
const serverNamespace = createRemoteServiceBinding({
|
|
services: [SessionDirectory, SessionManagement, PresentationPlugins],
|
|
transport: createLoopbackServiceTransport(serverProvider),
|
|
bound: false,
|
|
});
|
|
const serverNamespaceReady = serverNamespace.ready.bind(serverNamespace);
|
|
const serverServices: ServerServiceSource = Object.assign(serverNamespace, {
|
|
acceptsUnavailableServices: false,
|
|
connection: connectionState,
|
|
async catalogue() {
|
|
return serverProvider.catalogue;
|
|
},
|
|
open() {
|
|
return {
|
|
use: serverNamespace.use.bind(serverNamespace),
|
|
observe: serverNamespace.observe.bind(serverNamespace),
|
|
async ready() {
|
|
await serverNamespace.rebind(true, BACKGROUND_CONTEXT);
|
|
await serverNamespaceReady(BACKGROUND_CONTEXT);
|
|
},
|
|
async dispose() {},
|
|
};
|
|
},
|
|
async ready() {
|
|
await serverNamespace.rebind(true, BACKGROUND_CONTEXT);
|
|
await serverNamespaceReady(BACKGROUND_CONTEXT);
|
|
},
|
|
});
|
|
const sessionNamespace = createRemoteServiceBinding({
|
|
services: [Models, AgentController, SessionPlugins, Transcript],
|
|
transport: createLoopbackServiceTransport(sessionProvider),
|
|
bound: false,
|
|
});
|
|
const sessionServices: SessionServiceSource = Object.assign(sessionNamespace, {
|
|
acceptsUnavailableServices: true,
|
|
attachment,
|
|
async catalogue() {
|
|
return [];
|
|
},
|
|
open() {
|
|
return {
|
|
use: sessionNamespace.use.bind(sessionNamespace),
|
|
observe: sessionNamespace.observe.bind(sessionNamespace),
|
|
ready: sessionNamespace.ready.bind(sessionNamespace),
|
|
async dispose() {},
|
|
};
|
|
},
|
|
async whenAttached(sessionId: string) {
|
|
await sessionNamespace.rebind(true, BACKGROUND_CONTEXT);
|
|
await sessionNamespace.ready(BACKGROUND_CONTEXT);
|
|
publishReplacement(attachment, { status: "attached", sessionId });
|
|
},
|
|
async whenDetached() {
|
|
await sessionNamespace.rebind(false, BACKGROUND_CONTEXT);
|
|
publishReplacement(attachment, { status: "detached" });
|
|
},
|
|
});
|
|
const server: ClientTuiServer = {
|
|
serverId,
|
|
radius: true,
|
|
server: serverServices,
|
|
session: sessionServices,
|
|
};
|
|
let finished = false;
|
|
const requestRender = vi.fn();
|
|
const ui = new TuiMainScreen(new ProcessTerminal());
|
|
const component = await ExperimentalClientTui.create({
|
|
command,
|
|
ui,
|
|
servers: [server],
|
|
requestRender,
|
|
finish() {
|
|
finished = true;
|
|
},
|
|
});
|
|
try {
|
|
expect(create).toHaveBeenCalledTimes(creates);
|
|
expect(prepareSessionPlugins).toHaveBeenCalledWith(
|
|
{
|
|
sessionId,
|
|
packagePaths:
|
|
"pluginPackages" in command
|
|
? command.pluginPackages.map((packagePath) => resolve(packagePath))
|
|
: null,
|
|
},
|
|
expect.anything(),
|
|
);
|
|
expect(attachment.value).toEqual({ status: "attached", sessionId });
|
|
expect(select).not.toHaveBeenCalled();
|
|
expect(component.render(80).join("\n")).toContain(`Server: ${serverId}`);
|
|
expect(component.render(80).join("\n")).toContain(`Session: ${sessionId}`);
|
|
expect(component.render(80).join("\n")).toContain("faux/faux-1");
|
|
expect(component.render(80).join("\n")).not.toContain("Experimental Sessions");
|
|
expect(component.render(80).join("\n")).not.toContain("Experimental Models");
|
|
|
|
// Startup selection is the only behavior specific to continue and plugin-selected Sessions.
|
|
if (kind !== "new") return;
|
|
|
|
component.handleInput("hello");
|
|
component.handleInput("\r");
|
|
await vi.waitFor(() => expect(prompts).toEqual(["hello"]));
|
|
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Working..."));
|
|
finishPrompt();
|
|
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("remote answer"));
|
|
expect(component.render(80).join("\n")).toContain("hello");
|
|
expect(component.render(80).join("\n")).not.toContain("Working...");
|
|
|
|
component.handleInput("/reload");
|
|
component.handleInput("\u001b");
|
|
component.handleInput("\r");
|
|
await vi.waitFor(() => {
|
|
expect(reloadPresentationPlugins).toHaveBeenCalledOnce();
|
|
expect(reloadSessionPlugins).toHaveBeenCalledOnce();
|
|
});
|
|
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Reloaded plugins."));
|
|
|
|
publishReplacement(attachment, { status: "detached" });
|
|
publishReplacement(connectionState, {
|
|
status: "disconnected",
|
|
since: "later",
|
|
reason: "network lost",
|
|
retryAt: null,
|
|
});
|
|
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("retrying"));
|
|
component.handleInput("\u0003");
|
|
expect(finished).toBe(true);
|
|
finished = false;
|
|
component.handleInput("\u0004");
|
|
expect(finished).toBe(true);
|
|
finished = false;
|
|
publishReplacement(connectionState, { status: "connecting", attempt: 1 });
|
|
publishReplacement(connectionState, { status: "connected", since: "reconnected" });
|
|
publishReplacement(attachment, { status: "attached", sessionId });
|
|
await vi.waitFor(() => expect(component.render(80).join("\n")).not.toContain("Reattaching"));
|
|
|
|
component.handleInput("/model");
|
|
component.handleInput("\u001b");
|
|
component.handleInput("\r");
|
|
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Select model:"));
|
|
component.handleInput("\u001b[B");
|
|
component.handleInput("\r");
|
|
await vi.waitFor(() =>
|
|
expect(select).toHaveBeenCalledWith({ provider: "test", modelId: "two" }, expect.anything()),
|
|
);
|
|
expect(modelsState.value.configuration.model).toEqual({ provider: "test", modelId: "two" });
|
|
await vi.waitFor(() => expect(component.render(80).join("\n")).not.toContain("Select model:"));
|
|
|
|
component.handleInput("/thinking");
|
|
component.handleInput("\u001b");
|
|
component.handleInput("\r");
|
|
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Select thinking level:"));
|
|
component.handleInput("\u001b[B");
|
|
component.handleInput("\r");
|
|
await vi.waitFor(() => expect(selectThinking).toHaveBeenCalledWith("high", expect.anything()));
|
|
expect(modelsState.value.configuration.thinkingLevel).toBe("high");
|
|
|
|
component.handleInput("\u0003");
|
|
await vi.waitFor(() => expect(finished).toBe(true));
|
|
|
|
await component.close();
|
|
const rendersAfterClose = requestRender.mock.calls.length;
|
|
directoryState.change(BACKGROUND_CONTEXT, (draft) => {
|
|
draft.revision = 3;
|
|
draft.sessions = [];
|
|
});
|
|
publishReplacement(attachment, { status: "detached" });
|
|
modelsState.change(BACKGROUND_CONTEXT, (draft) => {
|
|
draft.refresh = { status: "refreshing" };
|
|
});
|
|
expect(requestRender).toHaveBeenCalledTimes(rendersAfterClose);
|
|
} finally {
|
|
await component.close();
|
|
await Promise.all([
|
|
serverNamespace.dispose(BACKGROUND_CONTEXT),
|
|
sessionNamespace.dispose(BACKGROUND_CONTEXT),
|
|
]);
|
|
serverProvider.dispose();
|
|
sessionProvider.dispose();
|
|
transcriptState.dispose();
|
|
await durable.close();
|
|
}
|
|
},
|
|
);
|
|
});
|