mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(coding-agent): remove legacy session RPC facade
Route discovery, management, and prompting through Chord services. Retain low-level lane watches and reattach Radius clients through SessionManagement.
This commit is contained in:
parent
7731f1cfe2
commit
984846b785
28 changed files with 463 additions and 1748 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# @earendil-works/pi-client
|
||||
|
||||
Transport-neutral client for Pi Session discovery, creation, attachment, and prompting.
|
||||
Transport-neutral client for the experimental Pi service protocol.
|
||||
|
||||
```ts
|
||||
import { Client, type ByteTransportFactory } from "@earendil-works/pi-client";
|
||||
|
|
@ -19,29 +19,21 @@ const client = await Client.connect({
|
|||
serverId: "01234567-89ab-4def-8123-456789abcdef",
|
||||
transportFactory,
|
||||
});
|
||||
const session = await client.createSession({});
|
||||
const attachment = await client.attachSession(session.sessionId);
|
||||
const watch = await client.watchSession(attachment.sessionId);
|
||||
await watch.start((event) => {
|
||||
if (event.type === "message_update" && event.frame.type === "text_delta") {
|
||||
process.stdout.write(event.frame.delta);
|
||||
}
|
||||
});
|
||||
const result = await client.promptSession(attachment.sessionId, "Summarize this session");
|
||||
await watch.dispose();
|
||||
const result = await client.request(
|
||||
{ serverId: client.hello.serverId },
|
||||
{ serviceId: "example.service", member: "read", args: [] },
|
||||
);
|
||||
```
|
||||
|
||||
The client verifies that the physical endpoint reports the expected logical `serverId`. Server-wide requests carry that ID, and every Session request carries the full live target `{ serverId, sessionId, attachmentId }`. The combined durable address prevents cross-server or cross-session misrouting; the server-generated attachment ID rejects delayed frames after switching or reattaching.
|
||||
|
||||
`createSession()` creates a presentation-safe Session summary without attaching or opening a worker; pass `id` to request an exact ID or omit it to generate one. The server derives private fields such as the working directory from its authenticated workspace context. `attachSession()` is a compatibility helper: the management call returns no routing data, while an ordered out-of-band attachment message updates the target and lets the helper return `{ sessionId, attachmentId }`. Multiple clients may attach to one Session. `promptSession()` accepts the protocol's serializable Harness prompt overloads and returns a structural `RunResult`.
|
||||
Typed server and Session APIs are provided by Chord service bindings owned by the application. `request()` and `subscribeService()` are low-level transport adapters. A service subscription returns a complete provider snapshot; the binding installs it and then calls `start()` to release updates buffered during hydration. `Client` applies ordered out-of-band attachment changes but deliberately does not construct typed service proxies or interpret service contracts.
|
||||
|
||||
`watchSession()` creates a main-lane watch and returns its authoritative snapshot without starting event delivery. Install the listener with `await watch.start(listener)`; this then flushes events buffered after the snapshot and continues with live events while `promptSession()` is pending. `watch.dispose()` stops server delivery and waits for already-received listener work. A disconnected watch is stale and cannot be reused after reconnection.
|
||||
`watchSession()` is the remaining low-level compatibility API. It creates a main-lane watch and returns its authoritative snapshot without starting event delivery. Install the listener with `await watch.start(listener)`; this then flushes events buffered after the snapshot and continues with live events. `watch.dispose()` stops server delivery and waits for already-received listener work. A disconnected watch is stale and cannot be reused after reconnection.
|
||||
|
||||
Plugin presentation hosts use `request()` and `subscribeService()` as low-level transport adapters for both the connected server and selected Session namespaces. A service subscription returns a complete provider snapshot; the host installs it and then calls `start()` to release updates buffered during hydration. `Client` applies out-of-band attachment changes but deliberately does not construct typed service proxies or interpret plugin contracts.
|
||||
On disconnect or disposal, pending requests reject locally, but accepted work may still complete remotely before the attachment is released. The client clears its live attachment route. It never reconnects or replays requests automatically. After disconnection, call `reconnect()`, attach through the application's management service again, and explicitly repeat only operations known to be safe.
|
||||
|
||||
On disconnect or disposal, pending prompts reject locally, but accepted work may still complete remotely before the attachment is released. The client clears its live attachment route. It never reconnects or replays requests automatically. After disconnection, call `reconnect()`, attach again, and explicitly repeat only operations known to be safe.
|
||||
|
||||
The experimental local coordinator only provides a stable endpoint and relays traffic. Replaceable server processes own session and worker lifecycle outside the public client protocol.
|
||||
The experimental local coordinator only provides a stable endpoint and relays traffic. Replaceable server processes own Session and worker lifecycle outside the public client protocol.
|
||||
|
||||
Call transport handlers as follows:
|
||||
|
||||
|
|
@ -75,7 +67,6 @@ const routes = await discoverUnixServers({ directory: "/run/user/1000/pi" });
|
|||
// [{ serverId: "...", path: "/run/user/1000/pi/<serverId>.sock" }]
|
||||
```
|
||||
|
||||
Malformed entries, non-sockets, stale or unresponsive endpoints, and server-ID mismatches are ignored. Discovery is read-only and probes at most 16 sockets concurrently. Unexpected filesystem and socket errors reject discovery.
|
||||
Pass `timeoutMs` to override the default probe timeout.
|
||||
Malformed entries, non-sockets, stale or unresponsive endpoints, and server-ID mismatches are ignored. Discovery is read-only and probes at most 16 sockets concurrently. Unexpected filesystem and socket errors reject discovery. Pass `timeoutMs` to override the default probe timeout.
|
||||
|
||||
`ClientOptions.maxFrameLength` bounds protocol payloads. `maxPendingBytes` bounds queued Unix transport output. Configure matching limits on both peers.
|
||||
|
|
|
|||
|
|
@ -5,12 +5,10 @@ import {
|
|||
createServiceSubscribeCall,
|
||||
createServiceUnsubscribeCall,
|
||||
encodeClientMessage,
|
||||
encodeServiceRpcCall,
|
||||
encodeLaneWatchRpcCall,
|
||||
isServerId,
|
||||
type LaneEvent,
|
||||
type PromptArguments,
|
||||
type PromptImage,
|
||||
type PromptMessage,
|
||||
LaneWatchRpc,
|
||||
type ProtocolRpcCall,
|
||||
ProtocolValidationError,
|
||||
parseServiceCatalogue,
|
||||
|
|
@ -22,13 +20,7 @@ import {
|
|||
type ServiceCatalogueEntry,
|
||||
type ServiceMode,
|
||||
type ServiceProviderUpdate,
|
||||
ServiceRpc,
|
||||
type ServiceRpcCall,
|
||||
type ServiceRpcResult,
|
||||
type ServiceSubscriptionSnapshot,
|
||||
type SessionAddress,
|
||||
type SessionCreateOptions,
|
||||
type SessionSummary,
|
||||
type SessionTarget,
|
||||
} from "@earendil-works/pi-protocol";
|
||||
import { Connection } from "./connection.ts";
|
||||
|
|
@ -71,7 +63,7 @@ export class Client {
|
|||
readonly #attachmentListeners = new Set<AttachmentChangeListener>();
|
||||
readonly #watchListeners = new Map<string, ActiveWatchListener>();
|
||||
readonly #serviceListeners = new Map<string, ActiveServiceListener>();
|
||||
readonly #rpc: ReturnType<typeof createRpcClient<typeof ServiceRpc>>;
|
||||
readonly #laneWatchRpc: ReturnType<typeof createRpcClient<typeof LaneWatchRpc>>;
|
||||
#requestSequence = 0;
|
||||
#serviceSubscriptionSequence = 0;
|
||||
#hello: ServerHello | undefined;
|
||||
|
|
@ -94,9 +86,9 @@ export class Client {
|
|||
onMessage: (message) => this.#handleMessage(message),
|
||||
onStateChange: (change) => this.#handleConnectionStateChange(change),
|
||||
});
|
||||
this.#rpc = createRpcClient(
|
||||
ServiceRpc,
|
||||
(call) => this.#request(this.#targetForCall(call), encodeServiceRpcCall(call)),
|
||||
this.#laneWatchRpc = createRpcClient(
|
||||
LaneWatchRpc,
|
||||
(call) => this.#request(this.#requireSessionTarget(), encodeLaneWatchRpcCall(call)),
|
||||
(message) => new ProtocolValidationError(message),
|
||||
);
|
||||
}
|
||||
|
|
@ -167,62 +159,6 @@ export class Client {
|
|||
return this.#request(target, call, signal);
|
||||
}
|
||||
|
||||
listSessions(): Promise<readonly SessionSummary[]> {
|
||||
return this.#rpc.list();
|
||||
}
|
||||
|
||||
createSession(options: SessionCreateOptions): Promise<ServiceRpcResult<"create">> {
|
||||
return this.#rpc.create(options);
|
||||
}
|
||||
|
||||
async attachSession(session: string | SessionAddress): Promise<ServiceRpcResult<"attach">> {
|
||||
const sessionId = typeof session === "string" ? session : session.sessionId;
|
||||
if (typeof session !== "string" && session.serverId !== this.#options.serverId) {
|
||||
throw new ServerError({ code: "wrong_server", message: "Session belongs to another server" });
|
||||
}
|
||||
const result = await this.#request(
|
||||
{ serverId: this.#options.serverId },
|
||||
{ serviceId: "pi.session-management", member: "attach", args: [sessionId] },
|
||||
);
|
||||
if (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"sessionId" in result &&
|
||||
"attachmentId" in result &&
|
||||
typeof result.sessionId === "string" &&
|
||||
typeof result.attachmentId === "string"
|
||||
) {
|
||||
this.#setAttachment({
|
||||
serverId: this.#options.serverId,
|
||||
sessionId: result.sessionId,
|
||||
attachmentId: result.attachmentId,
|
||||
});
|
||||
}
|
||||
const attachment = this.#attachment;
|
||||
if (attachment?.sessionId !== sessionId) {
|
||||
const error = new ProtocolValidationError("Session attach completed without a matching attachment route");
|
||||
this.#connection.fail(error);
|
||||
throw error;
|
||||
}
|
||||
return { sessionId, attachmentId: attachment.attachmentId };
|
||||
}
|
||||
|
||||
promptSession(sessionId: string, text: string, images?: PromptImage[]): Promise<ServiceRpcResult<"prompt">>;
|
||||
promptSession(sessionId: string, message: PromptMessage | PromptMessage[]): Promise<ServiceRpcResult<"prompt">>;
|
||||
promptSession(
|
||||
sessionId: string,
|
||||
message: string | PromptMessage | PromptMessage[],
|
||||
images?: PromptImage[],
|
||||
): Promise<ServiceRpcResult<"prompt">> {
|
||||
this.#requireSessionTarget(sessionId);
|
||||
if (typeof message === "string") {
|
||||
const prompt: PromptArguments = images === undefined ? [message] : [message, images];
|
||||
return this.#rpc.prompt(prompt);
|
||||
}
|
||||
if (Array.isArray(message)) return this.#rpc.prompt([message]);
|
||||
return this.#rpc.prompt([message]);
|
||||
}
|
||||
|
||||
async serviceCatalogue(target: RpcTarget, signal?: AbortSignal): Promise<readonly ServiceCatalogueEntry[]> {
|
||||
const result = await this.#request(target, createServiceCatalogueCall(), signal);
|
||||
try {
|
||||
|
|
@ -301,7 +237,7 @@ export class Client {
|
|||
|
||||
async watchSession(sessionId: string): Promise<LaneWatch> {
|
||||
this.#requireSessionTarget(sessionId);
|
||||
const { watchId, snapshot } = await this.#rpc.watch();
|
||||
const { watchId, snapshot } = await this.#laneWatchRpc.watch();
|
||||
const connection = this.#hello;
|
||||
let currentSnapshot = snapshot;
|
||||
let state: "ready" | "starting" | "started" | "disposed" = "ready";
|
||||
|
|
@ -321,7 +257,7 @@ export class Client {
|
|||
this.#watchListeners.set(watchId, { listener, deliveryTail: Promise.resolve() });
|
||||
try {
|
||||
this.#requireSessionTarget(sessionId);
|
||||
await this.#rpc.startWatch(watchId);
|
||||
await this.#laneWatchRpc.startWatch(watchId);
|
||||
if (state === "starting") state = "started";
|
||||
} catch (error) {
|
||||
this.#watchListeners.delete(watchId);
|
||||
|
|
@ -332,7 +268,7 @@ export class Client {
|
|||
resnapshot: async () => {
|
||||
if (state === "disposed") throw new Error("Lane watch is disposed");
|
||||
this.#requireSessionTarget(sessionId);
|
||||
const refreshed = await this.#rpc.resnapshotWatch(watchId);
|
||||
const refreshed = await this.#laneWatchRpc.resnapshotWatch(watchId);
|
||||
currentSnapshot = refreshed.snapshot;
|
||||
return currentSnapshot;
|
||||
},
|
||||
|
|
@ -342,7 +278,7 @@ export class Client {
|
|||
const active = this.#watchListeners.get(watchId);
|
||||
try {
|
||||
if (this.connected && this.#hello === connection && this.#attachment?.sessionId === sessionId) {
|
||||
await this.#rpc.stopWatch(watchId);
|
||||
await this.#laneWatchRpc.stopWatch(watchId);
|
||||
}
|
||||
await active?.deliveryTail;
|
||||
} finally {
|
||||
|
|
@ -494,21 +430,6 @@ export class Client {
|
|||
return this.dispose();
|
||||
}
|
||||
|
||||
#targetForCall(call: ServiceRpcCall): RpcTarget {
|
||||
switch (call.method) {
|
||||
case "list":
|
||||
case "create":
|
||||
case "attach":
|
||||
return { serverId: this.#options.serverId };
|
||||
case "prompt":
|
||||
case "watch":
|
||||
case "startWatch":
|
||||
case "resnapshotWatch":
|
||||
case "stopWatch":
|
||||
return this.#requireSessionTarget();
|
||||
}
|
||||
}
|
||||
|
||||
#requireSessionTarget(sessionId?: string): SessionTarget {
|
||||
const attachment = this.#attachment;
|
||||
if (attachment === undefined || (sessionId !== undefined && attachment.sessionId !== sessionId)) {
|
||||
|
|
|
|||
|
|
@ -9,11 +9,29 @@ import { describe, expect, test, vi } from "vitest";
|
|||
import { type ByteTransportFactory, Client, ClientDisposedError, DisconnectedError } from "../src/index.ts";
|
||||
import { MemoryByteServer } from "./support.ts";
|
||||
|
||||
async function connectClient(
|
||||
server: MemoryByteServer,
|
||||
serverId = "00000000-0000-4000-8000-000000000001",
|
||||
): Promise<Client> {
|
||||
return Client.connect({ serverId, transportFactory: (handlers) => server.connect(handlers) });
|
||||
const serverId = "00000000-0000-4000-8000-000000000001";
|
||||
const serverTarget = { serverId } as const;
|
||||
|
||||
async function connectClient(server: MemoryByteServer, expectedServerId = serverId): Promise<Client> {
|
||||
return Client.connect({ serverId: expectedServerId, transportFactory: (handlers) => server.connect(handlers) });
|
||||
}
|
||||
|
||||
async function attachClient(client: Client, server: MemoryByteServer, sessionId: string): Promise<void> {
|
||||
const expectedMessages = server.messages.length + 1;
|
||||
const attaching = client.request(serverTarget, {
|
||||
serviceId: "pi.session-management",
|
||||
member: "attach",
|
||||
args: [sessionId],
|
||||
});
|
||||
await server.waitForMessages(expectedMessages);
|
||||
const request = server.messages.at(-1);
|
||||
if (request?.type !== "request") throw new Error("Missing attach request");
|
||||
server.send({
|
||||
type: "attachment",
|
||||
attachment: { serverId, sessionId, attachmentId: `attachment-${sessionId}` },
|
||||
});
|
||||
server.send({ type: "response", id: request.id, ok: true, result: null });
|
||||
await attaching;
|
||||
}
|
||||
|
||||
test("requires a canonical UUIDv4 server identity", () => {
|
||||
|
|
@ -26,45 +44,26 @@ describe("Client service operations", () => {
|
|||
test("connects only to the expected logical server", async () => {
|
||||
const matching = new MemoryByteServer();
|
||||
const client = await connectClient(matching);
|
||||
expect(client.hello).toMatchObject({ serverId: "00000000-0000-4000-8000-000000000001" });
|
||||
expect(client.hello).toMatchObject({ serverId });
|
||||
await client.dispose();
|
||||
|
||||
const wrong = new MemoryByteServer("00000000-0000-4000-8000-000000000002");
|
||||
await expect(connectClient(wrong)).rejects.toBeInstanceOf(ProtocolValidationError);
|
||||
});
|
||||
|
||||
test("rejects a Session address belonging to another server", async () => {
|
||||
const server = new MemoryByteServer();
|
||||
const client = await connectClient(server);
|
||||
await expect(
|
||||
client.attachSession({
|
||||
serverId: "00000000-0000-4000-8000-000000000002",
|
||||
sessionId: "session-1",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "wrong_server" });
|
||||
expect(server.messages).toHaveLength(1);
|
||||
await client.dispose();
|
||||
});
|
||||
|
||||
test("updates attachment state from out-of-band server routing", async () => {
|
||||
const server = new MemoryByteServer();
|
||||
const client = await connectClient(server);
|
||||
const changes: Array<string | undefined> = [];
|
||||
client.onAttachmentChange((attachment) => changes.push(attachment?.sessionId));
|
||||
|
||||
const attaching = client.attachSession("session-1");
|
||||
await server.waitForMessages(2);
|
||||
server.send({
|
||||
type: "attachment",
|
||||
attachment: {
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
sessionId: "session-1",
|
||||
attachmentId: "attachment-1",
|
||||
},
|
||||
await attachClient(client, server, "session-1");
|
||||
expect(client.attachment).toMatchObject({ sessionId: "session-1", attachmentId: "attachment-session-1" });
|
||||
expect(server.messages[1]).toMatchObject({
|
||||
type: "request",
|
||||
target: serverTarget,
|
||||
call: { serviceId: "pi.session-management", member: "attach", args: ["session-1"] },
|
||||
});
|
||||
server.send({ type: "response", id: "request-1", ok: true });
|
||||
await expect(attaching).resolves.toEqual({ sessionId: "session-1", attachmentId: "attachment-1" });
|
||||
expect(client.attachment).toMatchObject({ sessionId: "session-1", attachmentId: "attachment-1" });
|
||||
|
||||
server.send({ type: "attachment", attachment: null });
|
||||
expect(client.attachment).toBeUndefined();
|
||||
|
|
@ -72,174 +71,10 @@ describe("Client service operations", () => {
|
|||
await client.dispose();
|
||||
});
|
||||
|
||||
test("addresses Session operations to the configured server", async () => {
|
||||
const server = new MemoryByteServer();
|
||||
const client = await connectClient(server);
|
||||
const listing = client.listSessions();
|
||||
await server.waitForMessages(2);
|
||||
expect(server.messages[1]).toEqual({
|
||||
type: "request",
|
||||
id: "request-1",
|
||||
target: { serverId: "00000000-0000-4000-8000-000000000001" },
|
||||
call: { serviceId: "pi.session-directory", member: "list", args: [] },
|
||||
});
|
||||
server.send({
|
||||
type: "response",
|
||||
id: "request-1",
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
sessionId: "session-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
await expect(listing).resolves.toEqual([
|
||||
{
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
sessionId: "session-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const creating = client.createSession({});
|
||||
await server.waitForMessages(3);
|
||||
expect(server.messages[2]).toMatchObject({
|
||||
type: "request",
|
||||
target: { serverId: "00000000-0000-4000-8000-000000000001" },
|
||||
call: { serviceId: "pi.session-management", member: "create", args: [{}] },
|
||||
});
|
||||
server.send({
|
||||
type: "response",
|
||||
id: "request-2",
|
||||
ok: true,
|
||||
result: {
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
sessionId: "session-2",
|
||||
createdAt: 2,
|
||||
},
|
||||
});
|
||||
await expect(creating).resolves.toEqual({
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
sessionId: "session-2",
|
||||
createdAt: 2,
|
||||
});
|
||||
|
||||
const attaching = client.attachSession("session-1");
|
||||
await server.waitForMessages(4);
|
||||
expect(server.messages[3]).toMatchObject({
|
||||
type: "request",
|
||||
target: { serverId: "00000000-0000-4000-8000-000000000001" },
|
||||
call: { serviceId: "pi.session-management", member: "attach", args: ["session-1"] },
|
||||
});
|
||||
server.send({
|
||||
type: "response",
|
||||
id: "request-3",
|
||||
ok: true,
|
||||
result: { sessionId: "session-1", attachmentId: "attachment-1" },
|
||||
});
|
||||
await expect(attaching).resolves.toEqual({ sessionId: "session-1", attachmentId: "attachment-1" });
|
||||
|
||||
const prompting = client.promptSession("session-1", "Hello");
|
||||
await server.waitForMessages(5);
|
||||
expect(server.messages[4]).toMatchObject({
|
||||
type: "request",
|
||||
target: {
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
sessionId: "session-1",
|
||||
attachmentId: "attachment-1",
|
||||
},
|
||||
call: { serviceId: "pi.chat", member: "prompt", args: [["Hello"]] },
|
||||
});
|
||||
const completed = {
|
||||
operationId: "run-1",
|
||||
kind: "run" as const,
|
||||
status: "completed" as const,
|
||||
fromTipId: null,
|
||||
tipId: "leaf-1",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
};
|
||||
server.send({
|
||||
type: "response",
|
||||
id: "request-4",
|
||||
ok: true,
|
||||
result: { ok: true, value: completed },
|
||||
});
|
||||
await expect(prompting).resolves.toEqual({ ok: true, value: completed });
|
||||
await client.dispose();
|
||||
});
|
||||
|
||||
test("serializes every supported prompt overload as one argument tuple", async () => {
|
||||
const server = new MemoryByteServer();
|
||||
const client = await connectClient(server);
|
||||
const attaching = client.attachSession("session-1");
|
||||
await server.waitForMessages(2);
|
||||
server.send({
|
||||
type: "response",
|
||||
id: "request-1",
|
||||
ok: true,
|
||||
result: { sessionId: "session-1", attachmentId: "attachment-1" },
|
||||
});
|
||||
await attaching;
|
||||
const calls = [
|
||||
client.promptSession("session-1", "text"),
|
||||
client.promptSession("session-1", "image", [{ type: "image", data: "aW1n", mimeType: "image/png" }]),
|
||||
client.promptSession("session-1", { role: "user", content: "message", timestamp: 1 }),
|
||||
client.promptSession("session-1", [
|
||||
{ role: "user", content: "first", timestamp: 1 },
|
||||
{ role: "user", content: "second", timestamp: 2 },
|
||||
]),
|
||||
];
|
||||
await server.waitForMessages(6);
|
||||
expect(
|
||||
server.messages.slice(2).map((message) => (message.type === "request" ? message.call : undefined)),
|
||||
).toEqual([
|
||||
{ serviceId: "pi.chat", member: "prompt", args: [["text"]] },
|
||||
{
|
||||
serviceId: "pi.chat",
|
||||
member: "prompt",
|
||||
args: [["image", [{ type: "image", data: "aW1n", mimeType: "image/png" }]]],
|
||||
},
|
||||
{ serviceId: "pi.chat", member: "prompt", args: [[{ role: "user", content: "message", timestamp: 1 }]] },
|
||||
{
|
||||
serviceId: "pi.chat",
|
||||
member: "prompt",
|
||||
args: [
|
||||
[
|
||||
[
|
||||
{ role: "user", content: "first", timestamp: 1 },
|
||||
{ role: "user", content: "second", timestamp: 2 },
|
||||
],
|
||||
],
|
||||
],
|
||||
},
|
||||
]);
|
||||
for (let index = 0; index < calls.length; index++) {
|
||||
server.send({
|
||||
type: "response",
|
||||
id: `request-${index + 2}`,
|
||||
ok: true,
|
||||
result: { ok: false, error: { _tag: "Closed", message: "closed" } },
|
||||
});
|
||||
}
|
||||
await expect(Promise.all(calls)).resolves.toHaveLength(4);
|
||||
await client.dispose();
|
||||
});
|
||||
|
||||
test("starts lane event delivery only after receiving the authoritative snapshot", async () => {
|
||||
const server = new MemoryByteServer();
|
||||
const client = await connectClient(server);
|
||||
const attaching = client.attachSession("session-1");
|
||||
await server.waitForMessages(2);
|
||||
server.send({
|
||||
type: "response",
|
||||
id: "request-1",
|
||||
ok: true,
|
||||
result: { sessionId: "session-1", attachmentId: "attachment-1" },
|
||||
});
|
||||
await attaching;
|
||||
await attachClient(client, server, "session-1");
|
||||
const opening = client.watchSession("session-1");
|
||||
await server.waitForMessages(3);
|
||||
expect(server.messages[2]).toMatchObject({ call: { serviceId: "pi.transcript", member: "watch", args: [] } });
|
||||
|
|
@ -352,15 +187,7 @@ describe("Client service operations", () => {
|
|||
test("buffers service updates until the subscription snapshot arrives", async () => {
|
||||
const server = new MemoryByteServer();
|
||||
const client = await connectClient(server);
|
||||
const attaching = client.attachSession("session-1");
|
||||
await server.waitForMessages(2);
|
||||
server.send({
|
||||
type: "response",
|
||||
id: "request-1",
|
||||
ok: true,
|
||||
result: { sessionId: "session-1", attachmentId: "attachment-1" },
|
||||
});
|
||||
await attaching;
|
||||
await attachClient(client, server, "session-1");
|
||||
const target = client.attachment!;
|
||||
const updates: string[] = [];
|
||||
const opening = client.subscribeService(target, "pi.models", "singleton", (update) => {
|
||||
|
|
@ -390,11 +217,7 @@ describe("Client service operations", () => {
|
|||
result: {
|
||||
serviceId: "pi.models",
|
||||
mode: "singleton",
|
||||
instances: [
|
||||
{
|
||||
members: [{ name: "state", kind: "state", sequence: 0, value: { revision: 0 } }],
|
||||
},
|
||||
],
|
||||
instances: [{ members: [{ name: "state", kind: "state", sequence: 0, value: { revision: 0 } }] }],
|
||||
},
|
||||
});
|
||||
const subscription = await opening;
|
||||
|
|
@ -415,35 +238,22 @@ describe("Client service operations", () => {
|
|||
await client.dispose();
|
||||
});
|
||||
|
||||
test("correlates out-of-order responses", async () => {
|
||||
test("correlates out-of-order generic service responses", async () => {
|
||||
const server = new MemoryByteServer();
|
||||
const client = await connectClient(server);
|
||||
const first = client.listSessions();
|
||||
const second = client.attachSession("session-1");
|
||||
const first = client.request(serverTarget, { serviceId: "test", member: "first", args: [] });
|
||||
const second = client.request(serverTarget, { serviceId: "test", member: "second", args: [] });
|
||||
await server.waitForMessages(3);
|
||||
server.send({
|
||||
type: "response",
|
||||
id: "request-2",
|
||||
ok: true,
|
||||
result: { sessionId: "session-1", attachmentId: "attachment-1" },
|
||||
});
|
||||
server.send({
|
||||
type: "response",
|
||||
id: "request-1",
|
||||
ok: true,
|
||||
result: [],
|
||||
});
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
[],
|
||||
{ sessionId: "session-1", attachmentId: "attachment-1" },
|
||||
]);
|
||||
server.send({ type: "response", id: "request-2", ok: true, result: "second" });
|
||||
server.send({ type: "response", id: "request-1", ok: true, result: "first" });
|
||||
await expect(Promise.all([first, second])).resolves.toEqual(["first", "second"]);
|
||||
await client.dispose();
|
||||
});
|
||||
|
||||
test("exposes bounded server errors", async () => {
|
||||
const server = new MemoryByteServer();
|
||||
const client = await connectClient(server);
|
||||
const attaching = client.attachSession("missing");
|
||||
const pending = client.request(serverTarget, { serviceId: "test", member: "missing", args: [] });
|
||||
await server.waitForMessages(2);
|
||||
server.send({
|
||||
type: "response",
|
||||
|
|
@ -451,7 +261,7 @@ describe("Client service operations", () => {
|
|||
ok: false,
|
||||
error: { code: "session_not_found", message: "Unknown session" },
|
||||
});
|
||||
await expect(attaching).rejects.toMatchObject({ code: "session_not_found" });
|
||||
await expect(pending).rejects.toMatchObject({ code: "session_not_found" });
|
||||
await client.dispose();
|
||||
});
|
||||
|
||||
|
|
@ -463,11 +273,7 @@ describe("Client service operations", () => {
|
|||
controller.abort(reason);
|
||||
|
||||
await expect(
|
||||
client.request(
|
||||
{ serverId: "00000000-0000-4000-8000-000000000001" },
|
||||
{ serviceId: "test", member: "noop", args: [] },
|
||||
controller.signal,
|
||||
),
|
||||
client.request(serverTarget, { serviceId: "test", member: "noop", args: [] }, controller.signal),
|
||||
).rejects.toBe(reason);
|
||||
expect(server.messages).toHaveLength(1);
|
||||
await client.dispose();
|
||||
|
|
@ -478,9 +284,8 @@ describe("Client service operations", () => {
|
|||
const client = await connectClient(server);
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("stop this request");
|
||||
const target = { serverId: "00000000-0000-4000-8000-000000000001" } as const;
|
||||
const pending = client.request(
|
||||
target,
|
||||
serverTarget,
|
||||
{ serviceId: "test", member: "mutate", args: [{ value: 42 }] },
|
||||
controller.signal,
|
||||
);
|
||||
|
|
@ -488,18 +293,14 @@ describe("Client service operations", () => {
|
|||
expect(server.messages[1]).toMatchObject({
|
||||
type: "request",
|
||||
id: "request-1",
|
||||
target,
|
||||
target: serverTarget,
|
||||
call: { serviceId: "test", member: "mutate", args: [{ value: 42 }] },
|
||||
});
|
||||
|
||||
controller.abort(reason);
|
||||
await expect(pending).rejects.toBe(reason);
|
||||
await server.waitForMessages(3);
|
||||
expect(server.messages[2]).toEqual({
|
||||
type: "cancel",
|
||||
id: "request-1",
|
||||
target,
|
||||
});
|
||||
expect(server.messages[2]).toEqual({ type: "cancel", id: "request-1", target: serverTarget });
|
||||
server.send({
|
||||
type: "response",
|
||||
id: "request-1",
|
||||
|
|
@ -513,11 +314,13 @@ describe("Client service operations", () => {
|
|||
test("rejects pending requests after disconnect or disposal", async () => {
|
||||
const server = new MemoryByteServer();
|
||||
const client = await connectClient(server);
|
||||
const listing = client.listSessions();
|
||||
const pending = client.request(serverTarget, { serviceId: "test", member: "pending", args: [] });
|
||||
server.disconnect();
|
||||
await expect(listing).rejects.toBeInstanceOf(DisconnectedError);
|
||||
await expect(pending).rejects.toBeInstanceOf(DisconnectedError);
|
||||
await client.dispose();
|
||||
await expect(client.listSessions()).rejects.toBeInstanceOf(ClientDisposedError);
|
||||
await expect(
|
||||
client.request(serverTarget, { serviceId: "test", member: "disposed", args: [] }),
|
||||
).rejects.toBeInstanceOf(ClientDisposedError);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -526,15 +329,9 @@ describe("Client connection lifecycle", () => {
|
|||
let closeCount = 0;
|
||||
let sendCount = 0;
|
||||
const client = new Client({
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
serverId,
|
||||
transportFactory: (handlers) => {
|
||||
handlers.onData(
|
||||
encodeServerMessage({
|
||||
type: "hello",
|
||||
version: PROTOCOL_VERSION,
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
}),
|
||||
);
|
||||
handlers.onData(encodeServerMessage({ type: "hello", version: PROTOCOL_VERSION, serverId }));
|
||||
return {
|
||||
async send() {
|
||||
sendCount += 1;
|
||||
|
|
@ -559,7 +356,7 @@ describe("Client connection lifecycle", () => {
|
|||
let handlers: Parameters<ByteTransportFactory>[0];
|
||||
let closeCount = 0;
|
||||
const client = new Client({
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
serverId,
|
||||
transportFactory: (createdHandlers) => {
|
||||
handlers = createdHandlers;
|
||||
return {
|
||||
|
|
@ -593,31 +390,20 @@ describe("Client connection lifecycle", () => {
|
|||
let connection = 0;
|
||||
const transportFactory: ByteTransportFactory = (handlers) =>
|
||||
(connection++ === 0 ? first : second).connect(handlers);
|
||||
const client = new Client({
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
transportFactory,
|
||||
});
|
||||
const client = new Client({ serverId, transportFactory });
|
||||
const states: string[] = [];
|
||||
client.onConnectionStateChange(({ state }) => states.push(state));
|
||||
await client.connect();
|
||||
const attaching = client.attachSession("session-1");
|
||||
await first.waitForMessages(2);
|
||||
first.send({
|
||||
type: "response",
|
||||
id: "request-1",
|
||||
ok: true,
|
||||
result: { sessionId: "session-1", attachmentId: "attachment-1" },
|
||||
});
|
||||
await attaching;
|
||||
const pending = client.promptSession("session-1", "Hello");
|
||||
await attachClient(client, first, "session-1");
|
||||
const target = client.attachment;
|
||||
if (target === undefined) throw new Error("Missing attachment");
|
||||
const pending = client.request(target, { serviceId: "test.session", member: "run", args: [] });
|
||||
await first.waitForMessages(3);
|
||||
expect(first.messages[2]).toMatchObject({ call: { serviceId: "pi.chat", member: "prompt" } });
|
||||
expect(first.messages[2]).toMatchObject({ call: { serviceId: "test.session", member: "run" } });
|
||||
first.disconnect();
|
||||
|
||||
await expect(pending).rejects.toBeInstanceOf(DisconnectedError);
|
||||
await expect(client.reconnect()).resolves.toMatchObject({
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
});
|
||||
await expect(client.reconnect()).resolves.toMatchObject({ serverId });
|
||||
expect(connection).toBe(2);
|
||||
expect(client.connected).toBe(true);
|
||||
expect(second.messages).toHaveLength(1);
|
||||
|
|
@ -628,7 +414,7 @@ describe("Client connection lifecycle", () => {
|
|||
test("reports transport failures without leaving requests pending", async () => {
|
||||
const server = new MemoryByteServer();
|
||||
const client = await connectClient(server);
|
||||
const pending = client.listSessions();
|
||||
const pending = client.request(serverTarget, { serviceId: "test", member: "pending", args: [] });
|
||||
await server.waitForMessages(2);
|
||||
server.error(new Error("read failed"));
|
||||
|
||||
|
|
@ -648,7 +434,7 @@ describe("Client connection lifecycle", () => {
|
|||
|
||||
const truncatedServer = new MemoryByteServer();
|
||||
const truncatedClient = await connectClient(truncatedServer);
|
||||
const pending = truncatedClient.listSessions();
|
||||
const pending = truncatedClient.request(serverTarget, { serviceId: "test", member: "pending", args: [] });
|
||||
await truncatedServer.waitForMessages(2);
|
||||
truncatedServer.sendRaw(new Uint8Array([0, 0, 0, 2, 1]));
|
||||
truncatedServer.disconnect();
|
||||
|
|
@ -663,12 +449,7 @@ describe("Client connection lifecycle", () => {
|
|||
test("disconnects when a response has no matching request", async () => {
|
||||
const server = new MemoryByteServer();
|
||||
const client = await connectClient(server);
|
||||
server.send({
|
||||
type: "response",
|
||||
id: "unknown-request",
|
||||
ok: true,
|
||||
result: [],
|
||||
});
|
||||
server.send({ type: "response", id: "unknown-request", ok: true, result: [] });
|
||||
|
||||
expect(client.connectionState).toBe("disconnected");
|
||||
expect(server.clientCloseCount).toBe(1);
|
||||
|
|
|
|||
|
|
@ -86,8 +86,10 @@ describe.runIf(process.platform !== "win32")("createUnixTransportFactory", () =>
|
|||
|
||||
try {
|
||||
await expect(client.connect()).resolves.toMatchObject({ serverId });
|
||||
await expect(client.listSessions()).resolves.toEqual([]);
|
||||
expect(receivedMembers).toEqual(["pi.session-directory.list"]);
|
||||
await expect(
|
||||
client.request({ serverId }, { serviceId: "test.server", member: "list", args: [] }),
|
||||
).resolves.toEqual([]);
|
||||
expect(receivedMembers).toEqual(["test.server.list"]);
|
||||
} finally {
|
||||
await client.dispose();
|
||||
}
|
||||
|
|
@ -118,7 +120,9 @@ describe.runIf(process.platform !== "win32")("createUnixTransportFactory", () =>
|
|||
|
||||
try {
|
||||
await client.connect();
|
||||
await expect(client.listSessions()).rejects.toMatchObject({
|
||||
await expect(
|
||||
client.request({ serverId }, { serviceId: "test.server", member: "list", args: [] }),
|
||||
).rejects.toMatchObject({
|
||||
name: "ProtocolValidationError",
|
||||
message: expect.stringMatching(/truncated/i),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ async function startServer(
|
|||
{
|
||||
sessions: {
|
||||
list: async () => [],
|
||||
create: async () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
openSession: async () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -149,9 +149,22 @@ export async function openClientRuntime(
|
|||
}
|
||||
activatedClient = undefined;
|
||||
clients.push(client);
|
||||
if (route.transport === "radius") reconnectors.push(new RadiusClientReconnect(client));
|
||||
const server = createServerServiceSource(client);
|
||||
serviceSources.push(server);
|
||||
if (route.transport === "radius") {
|
||||
const reconnectServices = server.open({
|
||||
services: [SessionManagement],
|
||||
assertAccess() {},
|
||||
onError() {},
|
||||
});
|
||||
const reconnectManagement = reconnectServices.use(SessionManagement);
|
||||
reconnectors.push(
|
||||
new RadiusClientReconnect(client, async (sessionId) => {
|
||||
await reconnectServices.ready(BACKGROUND_CONTEXT);
|
||||
await reconnectManagement.attach(sessionId, BACKGROUND_CONTEXT);
|
||||
}),
|
||||
);
|
||||
}
|
||||
const session = createSessionServiceSource(client);
|
||||
serviceSources.push(session);
|
||||
servers.push({ route, client, server, session });
|
||||
|
|
|
|||
|
|
@ -8,16 +8,12 @@ import type {
|
|||
Entry,
|
||||
HarnessEvent,
|
||||
LaneSnapshot as HarnessLaneSnapshot,
|
||||
RunResult as HarnessRunResult,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import type {
|
||||
AssistantMessageFrame as AiAssistantMessageFrame,
|
||||
AssistantMessage,
|
||||
DeferredHandle,
|
||||
ImageContent,
|
||||
TextContent,
|
||||
ThinkingContent,
|
||||
ToolCall,
|
||||
ToolResultMessage,
|
||||
Usage,
|
||||
UserMessage,
|
||||
|
|
@ -29,17 +25,11 @@ import type {
|
|||
LaneEvent,
|
||||
LaneSnapshot,
|
||||
OperationResultRecord,
|
||||
PromptArguments,
|
||||
PromptImage,
|
||||
PromptMessage,
|
||||
RunResult,
|
||||
ToolOutput,
|
||||
} from "@earendil-works/pi-protocol";
|
||||
|
||||
type HarnessPromptArguments =
|
||||
| [text: string]
|
||||
| [text: string, images: ImageContent[]]
|
||||
| [message: AgentMessage | AgentMessage[]];
|
||||
type WireAssistantMessage = Extract<PromptMessage, { role: "assistant" }>;
|
||||
type WireUserMessage = Extract<PromptMessage, { role: "user" }>;
|
||||
type WireToolResultMessage = Extract<PromptMessage, { role: "toolResult" }>;
|
||||
|
|
@ -65,156 +55,6 @@ export type HarnessWireAdapterCompatibility = [
|
|||
Assert<SameKeys<WireBranchSummaryMessage, BranchSummaryMessage>>,
|
||||
Assert<SameKeys<WireCompactionSummaryMessage, CompactionSummaryMessage>>,
|
||||
];
|
||||
type WireRunValue = Extract<RunResult, { ok: true }>["value"];
|
||||
type WireRunError = Extract<RunResult, { ok: false }>["error"];
|
||||
|
||||
export function toHarnessPromptArguments(prompt: PromptArguments): HarnessPromptArguments {
|
||||
if (typeof prompt[0] === "string") {
|
||||
return prompt.length === 1 ? [prompt[0]] : [prompt[0], prompt[1].map(toHarnessImage)];
|
||||
}
|
||||
return [Array.isArray(prompt[0]) ? prompt[0].map(toHarnessMessage) : toHarnessMessage(prompt[0])];
|
||||
}
|
||||
|
||||
export function toWireRunResult(result: HarnessRunResult): RunResult {
|
||||
return result.ok
|
||||
? { ok: true, value: toWireRunValue(result.value) }
|
||||
: { ok: false, error: toWireRunError(result.error) };
|
||||
}
|
||||
|
||||
function toHarnessImage(image: PromptImage): ImageContent {
|
||||
return { type: "image", data: image.data, mimeType: image.mimeType };
|
||||
}
|
||||
|
||||
function toHarnessMessage(message: PromptMessage): AgentMessage {
|
||||
switch (message.role) {
|
||||
case "user":
|
||||
return {
|
||||
role: "user",
|
||||
content:
|
||||
typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content.map((content) =>
|
||||
content.type === "text"
|
||||
? {
|
||||
type: "text",
|
||||
text: content.text,
|
||||
...(content.textSignature === undefined
|
||||
? {}
|
||||
: { textSignature: content.textSignature }),
|
||||
}
|
||||
: toHarnessImage(content),
|
||||
),
|
||||
timestamp: message.timestamp,
|
||||
} satisfies UserMessage;
|
||||
case "assistant":
|
||||
return {
|
||||
role: "assistant",
|
||||
content: message.content.map((content): TextContent | ThinkingContent | ToolCall => {
|
||||
switch (content.type) {
|
||||
case "text":
|
||||
return {
|
||||
type: "text",
|
||||
text: content.text,
|
||||
...(content.textSignature === undefined ? {} : { textSignature: content.textSignature }),
|
||||
};
|
||||
case "thinking":
|
||||
return {
|
||||
type: "thinking",
|
||||
thinking: content.thinking,
|
||||
...(content.thinkingSignature === undefined
|
||||
? {}
|
||||
: { thinkingSignature: content.thinkingSignature }),
|
||||
...(content.redacted === undefined ? {} : { redacted: content.redacted }),
|
||||
};
|
||||
case "toolCall":
|
||||
return {
|
||||
type: "toolCall",
|
||||
id: content.id,
|
||||
name: content.name,
|
||||
arguments: content.arguments,
|
||||
...(content.thoughtSignature === undefined
|
||||
? {}
|
||||
: { thoughtSignature: content.thoughtSignature }),
|
||||
...(content.namespace === undefined ? {} : { namespace: content.namespace }),
|
||||
};
|
||||
default:
|
||||
return assertNever(content);
|
||||
}
|
||||
}),
|
||||
api: message.api,
|
||||
provider: message.provider,
|
||||
model: message.model,
|
||||
...(message.responseModel === undefined ? {} : { responseModel: message.responseModel }),
|
||||
...(message.responseId === undefined ? {} : { responseId: message.responseId }),
|
||||
...(message.diagnostics === undefined ? {} : { diagnostics: message.diagnostics }),
|
||||
usage: toHarnessUsage(message.usage),
|
||||
stopReason: message.stopReason,
|
||||
...(message.deferred === undefined ? {} : { deferred: toHarnessDeferred(message.deferred) }),
|
||||
...(message.errorMessage === undefined ? {} : { errorMessage: message.errorMessage }),
|
||||
...(message.rawStopReason === undefined ? {} : { rawStopReason: message.rawStopReason }),
|
||||
...(message.endTurn === undefined ? {} : { endTurn: message.endTurn }),
|
||||
timestamp: message.timestamp,
|
||||
} satisfies AssistantMessage;
|
||||
case "toolResult":
|
||||
return {
|
||||
role: "toolResult",
|
||||
toolCallId: message.toolCallId,
|
||||
toolName: message.toolName,
|
||||
content: message.content.map((content) =>
|
||||
content.type === "text"
|
||||
? {
|
||||
type: "text",
|
||||
text: content.text,
|
||||
...(content.textSignature === undefined ? {} : { textSignature: content.textSignature }),
|
||||
}
|
||||
: toHarnessImage(content),
|
||||
),
|
||||
...(message.details === undefined ? {} : { details: message.details }),
|
||||
...(message.usage === undefined ? {} : { usage: toHarnessUsage(message.usage) }),
|
||||
...(message.addedToolNames === undefined ? {} : { addedToolNames: [...message.addedToolNames] }),
|
||||
isError: message.isError,
|
||||
timestamp: message.timestamp,
|
||||
} satisfies ToolResultMessage;
|
||||
case "bashExecution":
|
||||
return {
|
||||
role: "bashExecution",
|
||||
command: message.command,
|
||||
output: message.output,
|
||||
exitCode: message.exitCode,
|
||||
cancelled: message.cancelled,
|
||||
truncated: message.truncated,
|
||||
...(message.fullOutputPath === undefined ? {} : { fullOutputPath: message.fullOutputPath }),
|
||||
timestamp: message.timestamp,
|
||||
...(message.excludeFromContext === undefined ? {} : { excludeFromContext: message.excludeFromContext }),
|
||||
} satisfies BashExecutionMessage;
|
||||
case "custom":
|
||||
return {
|
||||
role: "custom",
|
||||
customType: message.customType,
|
||||
content: message.content,
|
||||
display: message.display,
|
||||
...(message.details === undefined ? {} : { details: message.details }),
|
||||
timestamp: message.timestamp,
|
||||
} satisfies CustomMessage<JsonValue>;
|
||||
case "branchSummary":
|
||||
return {
|
||||
role: "branchSummary",
|
||||
summary: message.summary,
|
||||
fromId: message.fromId,
|
||||
timestamp: message.timestamp,
|
||||
} satisfies BranchSummaryMessage;
|
||||
case "compactionSummary":
|
||||
return {
|
||||
role: "compactionSummary",
|
||||
summary: message.summary,
|
||||
tokensBefore: message.tokensBefore,
|
||||
timestamp: message.timestamp,
|
||||
} satisfies CompactionSummaryMessage;
|
||||
default:
|
||||
return assertNever(message);
|
||||
}
|
||||
}
|
||||
|
||||
function toHarnessUsage(usage: Usage): Usage {
|
||||
return {
|
||||
input: usage.input,
|
||||
|
|
@ -234,25 +74,6 @@ function toHarnessUsage(usage: Usage): Usage {
|
|||
};
|
||||
}
|
||||
|
||||
function toHarnessDeferred(deferred: DeferredHandle): DeferredHandle {
|
||||
return {
|
||||
provider: deferred.provider,
|
||||
modelId: deferred.modelId,
|
||||
api: deferred.api,
|
||||
id: deferred.id,
|
||||
...(deferred.expiresAt === undefined ? {} : { expiresAt: deferred.expiresAt }),
|
||||
...(deferred.pollAfterMs === undefined ? {} : { pollAfterMs: deferred.pollAfterMs }),
|
||||
...(deferred.data === undefined ? {} : { data: deferred.data }),
|
||||
};
|
||||
}
|
||||
|
||||
function toWireRunValue(value: Extract<HarnessRunResult, { ok: true }>["value"]): WireRunValue {
|
||||
if (value.status === "suspended") {
|
||||
return { operationId: value.operationId, status: "suspended", deferred: toWireDeferred(value.deferred) };
|
||||
}
|
||||
return toWireOperationResult(value);
|
||||
}
|
||||
|
||||
function toWireOperationResult(value: NonNullable<HarnessLaneSnapshot["lastResult"]>): OperationResultRecord {
|
||||
const base = {
|
||||
operationId: value.operationId,
|
||||
|
|
@ -275,29 +96,6 @@ function toWireOperationResult(value: NonNullable<HarnessLaneSnapshot["lastResul
|
|||
};
|
||||
}
|
||||
|
||||
function toWireRunError(error: Extract<HarnessRunResult, { ok: false }>["error"]): WireRunError {
|
||||
switch (error._tag) {
|
||||
case "LaneBusy":
|
||||
return {
|
||||
_tag: "LaneBusy",
|
||||
lane: error.lane,
|
||||
operationId: error.operationId,
|
||||
operationKind: error.operationKind,
|
||||
message: error.message,
|
||||
};
|
||||
case "InvalidMessage":
|
||||
return { _tag: "InvalidMessage", lane: error.lane, reason: error.reason, message: error.message };
|
||||
case "UnknownSkill":
|
||||
return { _tag: "UnknownSkill", name: error.name, message: error.message };
|
||||
case "UnknownTemplate":
|
||||
return { _tag: "UnknownTemplate", name: error.name, message: error.message };
|
||||
case "Closed":
|
||||
return { _tag: "Closed", message: error.message };
|
||||
default:
|
||||
return assertNever(error);
|
||||
}
|
||||
}
|
||||
|
||||
function toWireAssistantMessage(message: AssistantMessage): WireAssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
|
|
|
|||
|
|
@ -332,6 +332,7 @@ export function createRadiusClientTransportFactory(options: {
|
|||
/** Reconnect one established Radius client and restore its last selected Session. */
|
||||
export class RadiusClientReconnect {
|
||||
readonly #client: Client;
|
||||
readonly #reattach: (sessionId: string) => Promise<void>;
|
||||
readonly #abortController = new AbortController();
|
||||
readonly #removeConnectionListener: () => void;
|
||||
readonly #removeAttachmentListener: () => void;
|
||||
|
|
@ -339,8 +340,9 @@ export class RadiusClientReconnect {
|
|||
#reconnecting: Promise<void> | undefined;
|
||||
#disposed = false;
|
||||
|
||||
constructor(client: Client) {
|
||||
constructor(client: Client, reattach: (sessionId: string) => Promise<void>) {
|
||||
this.#client = client;
|
||||
this.#reattach = reattach;
|
||||
this.#desiredSessionId = client.attachment?.sessionId;
|
||||
this.#removeAttachmentListener = client.onAttachmentChange((attachment) => {
|
||||
if (attachment !== undefined) this.#desiredSessionId = attachment.sessionId;
|
||||
|
|
@ -377,7 +379,7 @@ export class RadiusClientReconnect {
|
|||
await delay(retryMs, this.#abortController.signal, false);
|
||||
await this.#client.reconnect();
|
||||
const sessionId = this.#desiredSessionId;
|
||||
if (sessionId !== undefined) await this.#client.attachSession(sessionId);
|
||||
if (sessionId !== undefined) await this.#reattach(sessionId);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (this.#disposed || this.#abortController.signal.aborted) return;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
||||
import { Client, ServerError as ClientServerError, DisconnectedError } from "@earendil-works/pi-client";
|
||||
import { createUnixTransportFactory, type UnixServerRoute } from "@earendil-works/pi-client/unix";
|
||||
import { isServerId, type ServerId, type SessionSummary } from "@earendil-works/pi-protocol";
|
||||
import { isServerId, type ServerId, type SessionCreateOptions, type SessionSummary } from "@earendil-works/pi-protocol";
|
||||
import {
|
||||
ServerError as RoutedServerError,
|
||||
type Server,
|
||||
|
|
@ -386,7 +386,10 @@ async function startServerBackend(
|
|||
for (const metadata of workers.trackedSessions) sessions.set(metadata.path, metadata);
|
||||
return [...sessions.values()];
|
||||
};
|
||||
const createSession: ServerHost<JsonlSessionMetadata>["sessions"]["create"] = async (createOptions, context) => {
|
||||
const createSession = async (
|
||||
createOptions: SessionCreateOptions,
|
||||
context: Context,
|
||||
): Promise<JsonlSessionMetadata> => {
|
||||
const session = await repo.create({ ...createOptions, cwd: process.cwd() }, context);
|
||||
try {
|
||||
return session.metadata;
|
||||
|
|
@ -451,7 +454,7 @@ async function startServerBackend(
|
|||
});
|
||||
const host: ServerHost<JsonlSessionMetadata> = {
|
||||
serverServices: serverServices.host,
|
||||
sessions: { list: listSessions, create: createSession },
|
||||
sessions: { list: listSessions },
|
||||
openSession: async (metadata, context) => {
|
||||
const selected = await options.resolveSessionPlugins(metadata, undefined, context);
|
||||
return workers.openSession(metadata, context, selected.manifestPaths);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import type { AgentLane, OperationResultRecord, SuspendedRun } from "@earendil-works/pi-agent-core";
|
||||
import type { ImageContent } from "@earendil-works/pi-ai";
|
||||
import type { PromptArguments } from "@earendil-works/pi-protocol";
|
||||
import { toHarnessPromptArguments } from "../harness-wire-adapter.ts";
|
||||
import type {
|
||||
AgentController as AgentControllerService,
|
||||
AgentOperationError,
|
||||
|
|
@ -107,11 +105,5 @@ function toAgentError(error: { readonly _tag: string; readonly message: string }
|
|||
}
|
||||
|
||||
function toTextPrompt(request: AgentPromptRequest): [message: string, images: ImageContent[] | undefined] {
|
||||
const prompt = toHarnessPromptArguments(toPromptArguments(request));
|
||||
if (typeof prompt[0] !== "string") throw new TypeError("AgentController prompt did not contain text");
|
||||
return [prompt[0], prompt.length === 1 ? undefined : prompt[1]];
|
||||
}
|
||||
|
||||
function toPromptArguments(request: AgentPromptRequest): PromptArguments {
|
||||
return request.images === null ? [request.message] : [request.message, request.images];
|
||||
return [request.message, request.images ?? undefined];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -245,7 +245,6 @@ export class SessionWorkerManager {
|
|||
const scope = this.#operationScope(worker, attachmentId);
|
||||
let released = false;
|
||||
return {
|
||||
prompt: (prompt, promptContext) => this.#operations(worker, scope, promptContext).prompt(prompt),
|
||||
invokeService: (call, publish, serviceContext) =>
|
||||
this.#invokeService(worker, scope, call, publish, serviceContext),
|
||||
watch: (watchContext) => this.#createLaneWatch(worker, scope, watchContext),
|
||||
|
|
|
|||
|
|
@ -25,11 +25,9 @@ import {
|
|||
defineRpc,
|
||||
LaneEventSchema,
|
||||
LaneSnapshotSchema,
|
||||
PromptArgumentsSchema,
|
||||
ProtocolRpcCallSchema,
|
||||
type RpcCall,
|
||||
type RpcResultUnion,
|
||||
RunResultSchema,
|
||||
type ServiceErrorCode,
|
||||
ServiceErrorCodeSchema,
|
||||
ServiceProviderUpdateSchema,
|
||||
|
|
@ -41,12 +39,7 @@ import { findInitialModel, resolveCliModel } from "../core/model-resolver.ts";
|
|||
import { ModelRuntime } from "../core/model-runtime.ts";
|
||||
import { SettingsManager } from "../core/settings-manager.ts";
|
||||
import { COORDINATOR_PROTOCOL_VERSION } from "./coordinator.ts";
|
||||
import {
|
||||
toHarnessPromptArguments,
|
||||
toWireLaneEvent,
|
||||
toWireLaneSnapshot,
|
||||
toWireRunResult,
|
||||
} from "./harness-wire-adapter.ts";
|
||||
import { toWireLaneEvent, toWireLaneSnapshot } from "./harness-wire-adapter.ts";
|
||||
import { createSessionPluginFacetLoader } from "./plugins/bundled.ts";
|
||||
import {
|
||||
consumeInternalProcessRole,
|
||||
|
|
@ -92,10 +85,6 @@ export const SessionWorkerOptionsSchema = StrictObject({
|
|||
export type SessionWorkerOptions = Static<typeof SessionWorkerOptionsSchema>;
|
||||
|
||||
export const SessionWorkerOperations = defineRpc({
|
||||
prompt: {
|
||||
args: Type.Tuple([PromptArgumentsSchema]),
|
||||
result: RunResultSchema,
|
||||
},
|
||||
watch: {
|
||||
args: Type.Tuple([]),
|
||||
result: StrictObject({ watchId: Type.String({ minLength: 1 }), snapshot: LaneSnapshotSchema }),
|
||||
|
|
@ -684,14 +673,6 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo
|
|||
readonly context: Context;
|
||||
}
|
||||
const dispatchWorkerOperation = createRpcDispatcher(SessionWorkerOperations, {
|
||||
prompt: async ({ context }: WorkerOperationContext, prompt) => {
|
||||
const args = toHarnessPromptArguments(prompt);
|
||||
const result =
|
||||
typeof args[0] === "string"
|
||||
? await lane.prompt(args[0], args[1], context)
|
||||
: await lane.prompt(args[0], context);
|
||||
return toWireRunResult(result);
|
||||
},
|
||||
watch: async ({ scope, context }: WorkerOperationContext, ..._args: never[]) => {
|
||||
const handle = await lane.watch(context);
|
||||
const watchId = randomUUID();
|
||||
|
|
|
|||
|
|
@ -1,29 +1,9 @@
|
|||
import {
|
||||
Closed,
|
||||
type HarnessEvent,
|
||||
type LaneSnapshot as HarnessLaneSnapshot,
|
||||
type RunResult as HarnessRunResult,
|
||||
InvalidMessage,
|
||||
LaneBusy,
|
||||
UnknownSkill,
|
||||
UnknownTemplate,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import type { HarnessEvent, LaneSnapshot as HarnessLaneSnapshot } from "@earendil-works/pi-agent-core";
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
LaneEventSchema,
|
||||
LaneSnapshotSchema,
|
||||
type PromptArguments,
|
||||
type PromptMessage,
|
||||
RunResultSchema,
|
||||
} from "@earendil-works/pi-protocol";
|
||||
import { LaneEventSchema, LaneSnapshotSchema } from "@earendil-works/pi-protocol";
|
||||
import { Check } from "typebox/value";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
toHarnessPromptArguments,
|
||||
toWireLaneEvent,
|
||||
toWireLaneSnapshot,
|
||||
toWireRunResult,
|
||||
} from "../src/experimental/harness-wire-adapter.ts";
|
||||
import { toWireLaneEvent, toWireLaneSnapshot } from "../src/experimental/harness-wire-adapter.ts";
|
||||
|
||||
const usage = {
|
||||
input: 1,
|
||||
|
|
@ -50,144 +30,7 @@ const finalMessage: AssistantMessage = {
|
|||
timestamp: 2,
|
||||
};
|
||||
|
||||
const wireFinalMessage = {
|
||||
role: "assistant",
|
||||
content: finalMessage.content,
|
||||
api: finalMessage.api,
|
||||
provider: finalMessage.provider,
|
||||
model: finalMessage.model,
|
||||
diagnostics: [{ type: "retry", timestamp: 1, details: { attempt: 2 } }],
|
||||
usage,
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
} satisfies PromptMessage;
|
||||
|
||||
const promptCases: [label: string, prompt: PromptArguments][] = [
|
||||
["text", ["hello"]],
|
||||
["text with image", ["hello", [{ type: "image", data: "aW1n", mimeType: "image/png" }]]],
|
||||
["one message", [{ role: "user", content: "hello", timestamp: 1 }]],
|
||||
];
|
||||
|
||||
describe("Harness wire adapter", () => {
|
||||
test("converts every closed wire prompt message role", () => {
|
||||
const prompt = toHarnessPromptArguments([
|
||||
[
|
||||
{ role: "user", content: "hello", timestamp: 1 },
|
||||
wireFinalMessage,
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "result" }],
|
||||
details: { path: "/tmp" },
|
||||
isError: false,
|
||||
timestamp: 3,
|
||||
},
|
||||
{
|
||||
role: "bashExecution",
|
||||
command: "pwd",
|
||||
output: "/tmp",
|
||||
cancelled: false,
|
||||
truncated: false,
|
||||
timestamp: 4,
|
||||
},
|
||||
{
|
||||
role: "custom",
|
||||
customType: "notice",
|
||||
content: "notice",
|
||||
display: true,
|
||||
details: { visible: true },
|
||||
timestamp: 5,
|
||||
},
|
||||
{ role: "branchSummary", summary: "branch", fromId: "entry-1", timestamp: 6 },
|
||||
{ role: "compactionSummary", summary: "compact", tokensBefore: 100, timestamp: 7 },
|
||||
],
|
||||
]);
|
||||
|
||||
expect(prompt[0]).toHaveLength(7);
|
||||
expect(prompt[0]).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: "bashExecution", exitCode: undefined }),
|
||||
expect.objectContaining({ role: "custom", details: { visible: true } }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test.each(promptCases)("converts %s prompt arguments", (_label, prompt) => {
|
||||
expect(toHarnessPromptArguments(prompt)).toEqual(prompt);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
ok: true,
|
||||
value: {
|
||||
operationId: "run-1",
|
||||
kind: "run",
|
||||
status: "completed",
|
||||
fromTipId: null,
|
||||
tipId: "leaf-1",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
ok: true,
|
||||
value: {
|
||||
operationId: "run-1",
|
||||
kind: "run",
|
||||
status: "aborted",
|
||||
fromTipId: "source",
|
||||
tipId: "leaf-1",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
ok: true,
|
||||
value: {
|
||||
operationId: "run-1",
|
||||
kind: "run",
|
||||
status: "failed",
|
||||
error: { code: "provider", message: "failed", details: { status: 500 } },
|
||||
fromTipId: null,
|
||||
tipId: "leaf-1",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
ok: true,
|
||||
value: {
|
||||
operationId: "run-1",
|
||||
status: "suspended",
|
||||
deferred: { provider: "test", modelId: "model", api: "test", id: "deferred-1", data: { row: 1 } },
|
||||
},
|
||||
},
|
||||
] satisfies HarnessRunResult[])("projects every Run outcome %#", (result) => {
|
||||
const wire = toWireRunResult(result);
|
||||
expect(Check(RunResultSchema, wire)).toBe(true);
|
||||
if (!result.ok || !wire.ok) throw new Error("Expected successful run result");
|
||||
expect(wire.value).toEqual(result.value);
|
||||
});
|
||||
|
||||
test.each([
|
||||
new LaneBusy({
|
||||
lane: "main",
|
||||
operationId: "operation-1",
|
||||
operationKind: "run",
|
||||
message: "busy",
|
||||
}),
|
||||
new InvalidMessage({ lane: "main", reason: "invalid", message: "invalid" }),
|
||||
new UnknownSkill({ name: "skill", message: "unknown" }),
|
||||
new UnknownTemplate({ name: "template", message: "unknown" }),
|
||||
new Closed({ message: "closed" }),
|
||||
])("projects Harness error %s without Error prototypes", (error) => {
|
||||
const wire = toWireRunResult({ ok: false, error });
|
||||
expect(Check(RunResultSchema, wire)).toBe(true);
|
||||
expect(wire).toMatchObject({ ok: false, error: { _tag: error._tag, message: error.message } });
|
||||
expect(wire.ok ? undefined : wire.error).not.toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
test("projects lane snapshots through the closed wire schema", () => {
|
||||
const snapshot: HarnessLaneSnapshot = {
|
||||
lane: "main",
|
||||
|
|
|
|||
|
|
@ -169,9 +169,6 @@ describe("experimental Radius relay", () => {
|
|||
async list() {
|
||||
return [];
|
||||
},
|
||||
async create() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
},
|
||||
async openSession() {
|
||||
throw new Error("not used");
|
||||
|
|
@ -308,7 +305,7 @@ describe("experimental Radius relay", () => {
|
|||
const connectionListeners = new Set<(change: { state: string }) => void>();
|
||||
const attachmentListeners = new Set<(attachment: { sessionId: string } | undefined) => void>();
|
||||
let attempts = 0;
|
||||
const attachSession = vi.fn(async () => ({ sessionId: "demo-1", attachmentId: "attachment-2" }));
|
||||
const reattach = vi.fn(async () => {});
|
||||
const client = {
|
||||
connected: true,
|
||||
connectionState: "connected",
|
||||
|
|
@ -329,21 +326,20 @@ describe("experimental Radius relay", () => {
|
|||
for (const listener of connectionListeners) listener({ state: "connected" });
|
||||
return { serverId };
|
||||
},
|
||||
attachSession,
|
||||
disconnect() {
|
||||
this.connected = false;
|
||||
this.connectionState = "disconnected";
|
||||
for (const listener of connectionListeners) listener({ state: "disconnected" });
|
||||
},
|
||||
};
|
||||
const reconnect = new RadiusClientReconnect(client as unknown as Client);
|
||||
const reconnect = new RadiusClientReconnect(client as unknown as Client, reattach);
|
||||
client.disconnect();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(attempts).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(attempts).toBe(2);
|
||||
expect(attachSession).toHaveBeenCalledWith("demo-1");
|
||||
expect(reattach).toHaveBeenCalledWith("demo-1");
|
||||
await reconnect.dispose();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -53,13 +53,23 @@ async function makeServer(): Promise<{ directory: string; runtime: RunningServer
|
|||
return { directory, runtime };
|
||||
}
|
||||
|
||||
async function attachSession(client: Client, sessionId: string): Promise<void> {
|
||||
const services = createServerServiceBinding(client, { services: [SessionManagement] });
|
||||
try {
|
||||
await services.ready(BACKGROUND_CONTEXT);
|
||||
await services.use(SessionManagement).attach(sessionId, BACKGROUND_CONTEXT);
|
||||
} finally {
|
||||
await services.dispose(BACKGROUND_CONTEXT);
|
||||
}
|
||||
}
|
||||
|
||||
async function attachClient(runtime: RunningServer, sessionId: string): Promise<Client> {
|
||||
const client = await Client.connect({
|
||||
serverId: runtime.serverId,
|
||||
transportFactory: createUnixTransportFactory({ path: runtime.socketPath }),
|
||||
});
|
||||
clients.add(client);
|
||||
await client.attachSession(sessionId);
|
||||
await attachSession(client, sessionId);
|
||||
return client;
|
||||
}
|
||||
|
||||
|
|
@ -410,7 +420,7 @@ describe("experimental durable server composition", () => {
|
|||
expect([...runtime.workerPids.keys()]).toEqual(["demo-1"]);
|
||||
const firstPid = runtime.workerPids.get("demo-1");
|
||||
expect(firstPid).toEqual(expect.any(Number));
|
||||
await expect(client.attachSession("demo-1")).resolves.toMatchObject({ sessionId: "demo-1" });
|
||||
await expect(attachSession(client, "demo-1")).resolves.toBeUndefined();
|
||||
expect(runtime.workerPids.get("demo-1")).toBe(firstPid);
|
||||
|
||||
const competing = await Client.connect({
|
||||
|
|
@ -418,7 +428,7 @@ describe("experimental durable server composition", () => {
|
|||
transportFactory: createUnixTransportFactory({ path: runtime.socketPath }),
|
||||
});
|
||||
clients.add(competing);
|
||||
await expect(competing.attachSession("demo-1")).resolves.toMatchObject({ sessionId: "demo-1" });
|
||||
await expect(attachSession(competing, "demo-1")).resolves.toBeUndefined();
|
||||
expect(runtime.workerPids.get("demo-1")).toBe(firstPid);
|
||||
});
|
||||
|
||||
|
|
@ -621,9 +631,9 @@ describe("experimental durable server composition", () => {
|
|||
onTestFinished(() => subscribe.mockRestore());
|
||||
|
||||
try {
|
||||
await client.attachSession("demo-2");
|
||||
await attachSession(client, "demo-2");
|
||||
expect(services.attachment.value).toEqual({ status: "attaching", sessionId: "demo-2" });
|
||||
await client.attachSession("demo-1");
|
||||
await attachSession(client, "demo-1");
|
||||
await services.whenAttached("demo-1", BACKGROUND_CONTEXT);
|
||||
expect(services.attachment.value).toEqual({ status: "attached", sessionId: "demo-1" });
|
||||
expect(models.state.value?.configuration.model).toEqual({
|
||||
|
|
@ -697,7 +707,7 @@ describe("experimental durable server composition", () => {
|
|||
|
||||
const replaced = observed[1]!.service;
|
||||
const replacedReplace = replaced.replace;
|
||||
await expect(client.attachSession("demo-2")).resolves.toMatchObject({ sessionId: "demo-2" });
|
||||
await expect(attachSession(client, "demo-2")).resolves.toBeUndefined();
|
||||
await services.whenAttached("demo-2", BACKGROUND_CONTEXT);
|
||||
expect(observed).toHaveLength(3);
|
||||
expect(services.attachment.value).toEqual({ status: "attached", sessionId: "demo-2" });
|
||||
|
|
@ -711,8 +721,6 @@ describe("experimental durable server composition", () => {
|
|||
});
|
||||
|
||||
test("streams prompt events through the worker-owned service provider", async ({ onTestFinished }) => {
|
||||
const legacyPrompt = vi.spyOn(Client.prototype, "promptSession");
|
||||
onTestFinished(() => legacyPrompt.mockRestore());
|
||||
const spawn = vi
|
||||
.spyOn(processRuntime, "spawnInternalProcess")
|
||||
.mockImplementation((role, args, options) =>
|
||||
|
|
@ -747,60 +755,6 @@ describe("experimental durable server composition", () => {
|
|||
"run_end",
|
||||
]),
|
||||
);
|
||||
expect(legacyPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Re-enable with runtime no-tool execution.
|
||||
test.skip("completes and persists a prompt through the worker-owned Harness", async ({ onTestFinished }) => {
|
||||
const spawn = vi
|
||||
.spyOn(processRuntime, "spawnInternalProcess")
|
||||
.mockImplementation((role, args, options) =>
|
||||
realSpawnInternalProcess(
|
||||
role,
|
||||
args,
|
||||
role === "session-worker" ? { ...options, entryUrl: fauxWorkerEntryUrl } : options,
|
||||
),
|
||||
);
|
||||
onTestFinished(() => spawn.mockRestore());
|
||||
const { runtime } = await makeServer();
|
||||
const client = await attachClient(runtime, "demo-1");
|
||||
const workerPid = runtime.workerPids.get("demo-1");
|
||||
expect(workerPid).toEqual(expect.any(Number));
|
||||
|
||||
const result = await client.promptSession("demo-1", "question");
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "run",
|
||||
status: "completed",
|
||||
tipId: expect.any(String),
|
||||
},
|
||||
});
|
||||
expect(runtime.workerPids.get("demo-1")).toBe(workerPid);
|
||||
expect(processExists(workerPid!)).toBe(true);
|
||||
if (!result.ok || result.value.status !== "completed" || result.value.tipId === null) {
|
||||
throw new Error("Expected a completed prompt with a terminal tip");
|
||||
}
|
||||
const finalEntryId = result.value.tipId;
|
||||
|
||||
await client.dispose();
|
||||
clients.delete(client);
|
||||
await expect.poll(() => runtime.workerPids.has("demo-1")).toBe(false);
|
||||
const { branch } = await readExperimentalSessionState(runtime.sessionDir, "demo-1");
|
||||
expect(branch).toHaveLength(2);
|
||||
expect(branch[0]).toMatchObject({
|
||||
type: "message",
|
||||
message: { role: "user", content: [{ type: "text", text: "question" }] },
|
||||
});
|
||||
expect(branch[1]).toMatchObject({
|
||||
id: finalEntryId,
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "deterministic remote answer" }],
|
||||
stopReason: "stop",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("uses legacy provider/model and thinking-suffix resolution", async () => {
|
||||
|
|
@ -823,7 +777,7 @@ describe("experimental durable server composition", () => {
|
|||
});
|
||||
clients.add(client);
|
||||
|
||||
await expect(client.attachSession("demo-1")).resolves.toMatchObject({ sessionId: "demo-1" });
|
||||
await expect(attachSession(client, "demo-1")).resolves.toBeUndefined();
|
||||
expect(runtime.workerPids.get("demo-1")).toEqual(expect.any(Number));
|
||||
});
|
||||
|
||||
|
|
@ -864,7 +818,7 @@ describe("experimental durable server composition", () => {
|
|||
|
||||
process.kill(firstPid!, "SIGKILL");
|
||||
await expect.poll(() => runtime.workerPids.has("demo-1")).toBe(false);
|
||||
await client.attachSession("demo-1");
|
||||
await attachSession(client, "demo-1");
|
||||
const replacementPid = runtime.workerPids.get("demo-1");
|
||||
expect(replacementPid).toEqual(expect.any(Number));
|
||||
expect(replacementPid).not.toBe(firstPid);
|
||||
|
|
@ -923,7 +877,6 @@ describe("experimental durable server composition", () => {
|
|||
serverId: first.serverId,
|
||||
transportFactory: createUnixTransportFactory({ path: first.socketPath }),
|
||||
});
|
||||
await client.listSessions();
|
||||
|
||||
const replacement = await startServer({ ...sessionWorkerModel, directory });
|
||||
servers.add(replacement);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { BACKGROUND_CONTEXT, type JsonlSessionMetadata } from "@earendil-works/pi-agent-core";
|
||||
import type { ProtocolRpcCall } from "@earendil-works/pi-protocol";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type { CoordinatorConnectionEvent } from "../src/experimental/coordinator.ts";
|
||||
import { SessionWorkerManager } from "../src/experimental/session-worker-manager.ts";
|
||||
|
|
@ -193,16 +194,9 @@ describe("Session worker lifecycle failures", () => {
|
|||
});
|
||||
|
||||
describe("Session worker operations", () => {
|
||||
const completedRun = {
|
||||
operationId: "run-1",
|
||||
kind: "run" as const,
|
||||
status: "completed" as const,
|
||||
fromTipId: null,
|
||||
tipId: "leaf-1",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
};
|
||||
test("correlates prompt results to the worker generation and attachment", async () => {
|
||||
const serviceCall = { serviceId: "test.session", member: "run", args: ["Hello"] } satisfies ProtocolRpcCall;
|
||||
|
||||
test("correlates service results to the worker generation and attachment", async () => {
|
||||
const { coordinator, workers, attachment, release } = await createAttachedWorker();
|
||||
coordinator.onSend = (peerId, payload) => {
|
||||
if (payload.type !== "operation") return;
|
||||
|
|
@ -219,26 +213,23 @@ describe("Session worker operations", () => {
|
|||
type: "operation_result",
|
||||
requestId: payload.requestId,
|
||||
scope,
|
||||
result: {
|
||||
ok: true,
|
||||
value: completedRun,
|
||||
},
|
||||
result: { result: { accepted: true } },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
if (attachment.invokeService === undefined) throw new Error("Missing service attachment");
|
||||
|
||||
await expect(attachment.prompt(["Hello"], BACKGROUND_CONTEXT)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: completedRun,
|
||||
await expect(attachment.invokeService(serviceCall, () => {}, BACKGROUND_CONTEXT)).resolves.toEqual({
|
||||
accepted: true,
|
||||
});
|
||||
const operation = coordinator.sent
|
||||
.map(({ payload }) => asObject(payload))
|
||||
.find(({ type }) => type === "operation");
|
||||
expect(operation).toMatchObject({
|
||||
scope: { serverConnectionId: "server-generation-1", attachmentId: expect.any(String) },
|
||||
call: { method: "prompt", args: [["Hello"]] },
|
||||
call: { method: "service", args: [serviceCall] },
|
||||
});
|
||||
workers.detach();
|
||||
await release();
|
||||
|
|
@ -260,17 +251,17 @@ describe("Session worker operations", () => {
|
|||
type: "operation_result",
|
||||
requestId: payload.requestId,
|
||||
scope: payload.scope,
|
||||
result: {
|
||||
ok: true,
|
||||
value: completedRun,
|
||||
},
|
||||
result: { result: { accepted: true } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
if (attachment.invokeService === undefined) throw new Error("Missing service attachment");
|
||||
|
||||
await expect(attachment.prompt(["Hello"], BACKGROUND_CONTEXT)).rejects.toThrow(/mismatched operation response/);
|
||||
await expect(attachment.invokeService(serviceCall, () => {}, BACKGROUND_CONTEXT)).rejects.toThrow(
|
||||
/mismatched operation response/,
|
||||
);
|
||||
workers.detach();
|
||||
await release();
|
||||
});
|
||||
|
|
@ -291,28 +282,29 @@ describe("Session worker operations", () => {
|
|||
type: "operation_result",
|
||||
requestId: payload.requestId,
|
||||
scope: null,
|
||||
result: {
|
||||
ok: true,
|
||||
value: completedRun,
|
||||
},
|
||||
result: { result: { accepted: true } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
if (attachment.invokeService === undefined) throw new Error("Missing service attachment");
|
||||
|
||||
await expect(attachment.prompt(["Hello"], BACKGROUND_CONTEXT)).rejects.toThrow(/invalid operation response/);
|
||||
await expect(attachment.invokeService(serviceCall, () => {}, BACKGROUND_CONTEXT)).rejects.toThrow(
|
||||
/invalid operation response/,
|
||||
);
|
||||
workers.detach();
|
||||
await release();
|
||||
});
|
||||
|
||||
test("rejects pending prompts on replacement without stopping the worker", async () => {
|
||||
test("rejects pending service calls on replacement without stopping the worker", async () => {
|
||||
const { coordinator, workers, attachment } = await createAttachedWorker();
|
||||
coordinator.onSend = () => {};
|
||||
const prompting = attachment.prompt(["Hello"], BACKGROUND_CONTEXT);
|
||||
if (attachment.invokeService === undefined) throw new Error("Missing service attachment");
|
||||
const calling = attachment.invokeService(serviceCall, () => {}, BACKGROUND_CONTEXT);
|
||||
workers.detach();
|
||||
|
||||
await expect(prompting).rejects.toThrow(/replaced during a worker operation/);
|
||||
await expect(calling).rejects.toThrow(/replaced during a worker operation/);
|
||||
expect(coordinator.sent.map(({ payload }) => asObject(payload).type)).not.toContain("shutdown");
|
||||
expect(workers.workerPids.size).toBe(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,11 +10,9 @@ Protocol version `6` carries the current server- and Session-scoped facet-servic
|
|||
- singleton and keyed-service subscription snapshots plus ordered state, spawn, and close updates;
|
||||
- correlated responses, request cancellation, out-of-band attachment updates, attachment-scoped events, and bounded protocol errors.
|
||||
|
||||
The transport carries `{ serviceId, instance?, member, args }` calls. A server target contains `{ serverId }`; a Session target contains `{ serverId, sessionId, attachmentId }`. Keyed addresses contain an application key and provider-owned generation so delayed calls cannot reach a replacement instance. Service subscriptions return a complete member/state snapshot before update delivery starts. Service updates carry state, keyed spawn/close, temporary unavailability, and complete singleton replacement; there is no remote service-event member kind. Session-directory state and management results contain no working directory, owner, storage, or parent metadata. Management `attach()` and `detach()` return no routing identifiers; the server publishes the selected live route in an out-of-band `attachment` message. `prompt()` targets that exact route. The real `Session` and `AgentHarness` remain process-local. Disconnecting releases only that presentation's attachment after admitted work settles.
|
||||
The transport carries `{ serviceId, instance?, member, args }` calls. A server target contains `{ serverId }`; a Session target contains `{ serverId, sessionId, attachmentId }`. Keyed addresses contain an application key and provider-owned generation so delayed calls cannot reach a replacement instance. Service subscriptions return a complete member/state snapshot before update delivery starts. Service updates carry state, keyed spawn/close, temporary unavailability, and complete singleton replacement; there is no remote service-event member kind. Session-directory state and management results contain no working directory, owner, storage, or parent metadata. Management `attach()` and `detach()` return no routing identifiers; the server publishes the selected live route in an out-of-band `attachment` message. The real `Session` and `AgentHarness` remain process-local. Disconnecting releases only that presentation's attachment after admitted service calls settle.
|
||||
|
||||
`PromptArguments` contains one serializable `AgentLane.prompt()` overload. `PromptMessage` is the protocol's closed set of built-in message DTOs; application-defined `AgentMessage` extensions are not accepted implicitly. `RunResult` is the wire-safe structural equivalent of the Harness result and contains no JavaScript `Error` instances.
|
||||
|
||||
The temporary `ServiceRpc` manifest still backs low-level client compatibility methods while those features migrate to services. Experimental presentations consume server-owned `SessionDirectory` and `SessionManagement` plus provider-generated Session service catalogues; there is no handwritten worker service inventory. `Models` has an implemented provider. `AgentController` is the presentation-safe command facade over the worker-owned main `AgentLane`; `Accounts` and revisioned `Transcript` retain placeholder providers while those features are completed. Session service calls route opaquely to the attached worker, where the provider validates and invokes them.
|
||||
Experimental presentations consume server-owned `SessionDirectory` and `SessionManagement` plus provider-generated Session service catalogues; there is no handwritten worker service inventory. `Models` has an implemented provider. `AgentController` is the presentation-safe command facade over the worker-owned main `AgentLane`. Session service calls route opaquely to the attached worker, where the provider validates and invokes them. The temporary `LaneWatchRpc` manifest remains only for snapshot-first main-lane observation.
|
||||
|
||||
A lane watch uses three RPCs so snapshot ordering does not depend on response/event scheduling: `watch()` creates a buffering watch and returns its authoritative snapshot, `startWatch()` flushes buffered events and begins live delivery, and `stopWatch()` releases it. Events carry their watch ID. Streaming message updates carry compact assistant-message frames rather than cumulative partial messages. Reconnection creates a new watch and snapshot rather than replaying old events.
|
||||
|
||||
|
|
|
|||
|
|
@ -154,15 +154,6 @@ export const PromptMessageSchema = Type.Union([
|
|||
]);
|
||||
export type PromptMessage = Static<typeof PromptMessageSchema>;
|
||||
|
||||
/** Serializable AgentLane.prompt() arguments, kept as one value across RPC boundaries. */
|
||||
export const PromptArgumentsSchema = Type.Union([
|
||||
Type.Tuple([Type.String()]),
|
||||
Type.Tuple([Type.String(), Type.Array(PromptImageSchema)]),
|
||||
Type.Tuple([PromptMessageSchema]),
|
||||
Type.Tuple([Type.Array(PromptMessageSchema)]),
|
||||
]);
|
||||
export type PromptArguments = Static<typeof PromptArgumentsSchema>;
|
||||
|
||||
const OperationErrorSchema = StrictObject({
|
||||
code: Type.String(),
|
||||
message: Type.String(),
|
||||
|
|
@ -184,40 +175,6 @@ export const OperationResultRecordSchema = Type.Union([
|
|||
StrictObject({ ...OperationResultBase, status: Type.Literal("failed"), error: OperationErrorSchema }),
|
||||
]);
|
||||
export type OperationResultRecord = Static<typeof OperationResultRecordSchema>;
|
||||
const RunValueSchema = Type.Union([
|
||||
OperationResultRecordSchema,
|
||||
StrictObject({
|
||||
operationId: IdSchema,
|
||||
status: Type.Literal("suspended"),
|
||||
deferred: DeferredHandleSchema,
|
||||
}),
|
||||
]);
|
||||
const RunErrorSchema = Type.Union([
|
||||
StrictObject({
|
||||
_tag: Type.Literal("LaneBusy"),
|
||||
lane: Type.String(),
|
||||
operationId: IdSchema,
|
||||
operationKind: Type.Union([Type.Literal("run"), Type.Literal("compaction"), Type.Literal("navigation")]),
|
||||
message: Type.String(),
|
||||
}),
|
||||
StrictObject({
|
||||
_tag: Type.Literal("InvalidMessage"),
|
||||
lane: Type.String(),
|
||||
reason: Type.String(),
|
||||
message: Type.String(),
|
||||
}),
|
||||
StrictObject({ _tag: Type.Literal("UnknownSkill"), name: Type.String(), message: Type.String() }),
|
||||
StrictObject({ _tag: Type.Literal("UnknownTemplate"), name: Type.String(), message: Type.String() }),
|
||||
StrictObject({ _tag: Type.Literal("Closed"), message: Type.String() }),
|
||||
]);
|
||||
|
||||
/** Wire-safe structural equivalent of AgentLane.prompt()'s RunResult. */
|
||||
export const RunResultSchema = Type.Union([
|
||||
StrictObject({ ok: Type.Literal(true), value: RunValueSchema }),
|
||||
StrictObject({ ok: Type.Literal(false), error: RunErrorSchema }),
|
||||
]);
|
||||
export type RunResult = Static<typeof RunResultSchema>;
|
||||
|
||||
export const ToolOutputSchema = StrictObject({
|
||||
content: Type.Array(Type.Union([TextContentSchema, PromptImageSchema])),
|
||||
details: Type.Optional(JsonValueSchema),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import Type, { type Static } from "typebox";
|
||||
import { Check } from "typebox/value";
|
||||
import { LaneEventSchema, LaneSnapshotSchema, PromptArgumentsSchema, RunResultSchema } from "./harness.ts";
|
||||
import { LaneEventSchema, LaneSnapshotSchema } from "./harness.ts";
|
||||
import { type JsonValue, JsonValueSchema } from "./json-value.ts";
|
||||
import {
|
||||
createRpcCallSchema,
|
||||
|
|
@ -44,24 +44,8 @@ export const SessionCreateOptionsSchema = StrictObject({
|
|||
});
|
||||
export type SessionCreateOptions = Static<typeof SessionCreateOptionsSchema>;
|
||||
|
||||
/** Temporary typed facade for the pre-plugin Session operations. */
|
||||
export const ServiceRpc = defineRpc({
|
||||
list: {
|
||||
args: Type.Tuple([]),
|
||||
result: Type.Array(SessionSummarySchema),
|
||||
},
|
||||
create: {
|
||||
args: Type.Tuple([SessionCreateOptionsSchema]),
|
||||
result: SessionSummarySchema,
|
||||
},
|
||||
attach: {
|
||||
args: Type.Tuple([SessionIdSchema]),
|
||||
result: StrictObject({ sessionId: SessionIdSchema, attachmentId: IdSchema }),
|
||||
},
|
||||
prompt: {
|
||||
args: Type.Tuple([PromptArgumentsSchema]),
|
||||
result: RunResultSchema,
|
||||
},
|
||||
/** Temporary typed facade for snapshot-first main-lane observation. */
|
||||
export const LaneWatchRpc = defineRpc({
|
||||
watch: {
|
||||
args: Type.Tuple([]),
|
||||
result: StrictObject({ watchId: IdSchema, snapshot: LaneSnapshotSchema }),
|
||||
|
|
@ -79,14 +63,14 @@ export const ServiceRpc = defineRpc({
|
|||
result: StrictObject({ watchId: IdSchema }),
|
||||
},
|
||||
});
|
||||
export type ServiceRpcManifest = typeof ServiceRpc;
|
||||
export type ServiceRpcMethod = RpcMethodName<ServiceRpcManifest>;
|
||||
export type ServiceRpcArgs<TMethod extends ServiceRpcMethod> = RpcArgs<ServiceRpcManifest, TMethod>;
|
||||
export type ServiceRpcResult<TMethod extends ServiceRpcMethod> = RpcResult<ServiceRpcManifest, TMethod>;
|
||||
export type ServiceRpcCall = RpcCall<ServiceRpcManifest>;
|
||||
export type ServiceRpcResultUnion = RpcResultUnion<ServiceRpcManifest>;
|
||||
export const ServiceRpcCallSchema = Type.Unsafe<ServiceRpcCall>(createRpcCallSchema(ServiceRpc));
|
||||
export const ServiceRpcResultSchema = Type.Unsafe<ServiceRpcResultUnion>(createRpcResultSchema(ServiceRpc));
|
||||
export type LaneWatchRpcManifest = typeof LaneWatchRpc;
|
||||
export type LaneWatchRpcMethod = RpcMethodName<LaneWatchRpcManifest>;
|
||||
export type LaneWatchRpcArgs<TMethod extends LaneWatchRpcMethod> = RpcArgs<LaneWatchRpcManifest, TMethod>;
|
||||
export type LaneWatchRpcResult<TMethod extends LaneWatchRpcMethod> = RpcResult<LaneWatchRpcManifest, TMethod>;
|
||||
export type LaneWatchRpcCall = RpcCall<LaneWatchRpcManifest>;
|
||||
export type LaneWatchRpcResultUnion = RpcResultUnion<LaneWatchRpcManifest>;
|
||||
export const LaneWatchRpcCallSchema = Type.Unsafe<LaneWatchRpcCall>(createRpcCallSchema(LaneWatchRpc));
|
||||
export const LaneWatchRpcResultSchema = Type.Unsafe<LaneWatchRpcResultUnion>(createRpcResultSchema(LaneWatchRpc));
|
||||
|
||||
export const ServiceModeSchema = Type.Union([Type.Literal("singleton"), Type.Literal("keyed")]);
|
||||
export type ServiceMode = Static<typeof ServiceModeSchema>;
|
||||
|
|
@ -236,29 +220,25 @@ export function decodeServiceControlCall(call: ProtocolRpcCall): ServiceControlC
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const ServiceRpcAddresses = {
|
||||
list: { serviceId: "pi.session-directory", member: "list" },
|
||||
create: { serviceId: "pi.session-management", member: "create" },
|
||||
attach: { serviceId: "pi.session-management", member: "attach" },
|
||||
prompt: { serviceId: "pi.chat", member: "prompt" },
|
||||
const LaneWatchRpcAddresses = {
|
||||
watch: { serviceId: "pi.transcript", member: "watch" },
|
||||
startWatch: { serviceId: "pi.transcript", member: "startWatch" },
|
||||
resnapshotWatch: { serviceId: "pi.transcript", member: "resnapshotWatch" },
|
||||
stopWatch: { serviceId: "pi.transcript", member: "stopWatch" },
|
||||
} as const satisfies Record<ServiceRpcMethod, { serviceId: string; member: string }>;
|
||||
} as const satisfies Record<LaneWatchRpcMethod, { serviceId: string; member: string }>;
|
||||
|
||||
/** Translate the current built-in contract to its generic service envelope. */
|
||||
export function encodeServiceRpcCall(call: ServiceRpcCall): ProtocolRpcCall {
|
||||
return { ...ServiceRpcAddresses[call.method], args: call.args };
|
||||
/** Translate the lane-watch contract to its generic service envelope. */
|
||||
export function encodeLaneWatchRpcCall(call: LaneWatchRpcCall): ProtocolRpcCall {
|
||||
return { ...LaneWatchRpcAddresses[call.method], args: call.args };
|
||||
}
|
||||
|
||||
/** Validate and decode one generic envelope against the current built-in contract. */
|
||||
export function decodeServiceRpcCall(call: ProtocolRpcCall): ServiceRpcCall | undefined {
|
||||
/** Validate and decode one generic envelope against the lane-watch contract. */
|
||||
export function decodeLaneWatchRpcCall(call: ProtocolRpcCall): LaneWatchRpcCall | undefined {
|
||||
if (call.instance !== undefined) return undefined;
|
||||
for (const [method, address] of Object.entries(ServiceRpcAddresses)) {
|
||||
for (const [method, address] of Object.entries(LaneWatchRpcAddresses)) {
|
||||
if (call.serviceId !== address.serviceId || call.member !== address.member) continue;
|
||||
const candidate = { method, args: call.args };
|
||||
return Check(ServiceRpcCallSchema, candidate) ? (candidate as ServiceRpcCall) : undefined;
|
||||
return Check(LaneWatchRpcCallSchema, candidate) ? (candidate as LaneWatchRpcCall) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -272,7 +252,6 @@ export const ServiceErrorCodeSchema = Type.Union([
|
|||
Type.Literal("service_instance_not_found"),
|
||||
Type.Literal("service_stale_instance"),
|
||||
Type.Literal("service_invalid_value"),
|
||||
Type.Literal("service_not_implemented"),
|
||||
]);
|
||||
export type ServiceErrorCode = Static<typeof ServiceErrorCodeSchema>;
|
||||
|
||||
|
|
|
|||
|
|
@ -10,30 +10,27 @@ import {
|
|||
createServiceSubscribeCall,
|
||||
createServiceUnsubscribeCall,
|
||||
decodeCbor,
|
||||
decodeLaneWatchRpcCall,
|
||||
decodeServiceControlCall,
|
||||
decodeServiceRpcCall,
|
||||
defineRpc,
|
||||
encodeCbor,
|
||||
encodeClientMessage,
|
||||
encodeFrame,
|
||||
encodeLaneWatchRpcCall,
|
||||
encodeServerMessage,
|
||||
encodeServiceRpcCall,
|
||||
FrameDecoder,
|
||||
isSupportedProtocolVersion,
|
||||
type LaneSnapshot,
|
||||
LaneWatchRpc,
|
||||
PROTOCOL_VERSION,
|
||||
PromptArgumentsSchema,
|
||||
ProtocolValidationError,
|
||||
parseClientMessage,
|
||||
parseServerMessage,
|
||||
parseServiceCatalogue,
|
||||
parseServiceSubscriptionSnapshot,
|
||||
type RunResult,
|
||||
RunResultSchema,
|
||||
type ServerHello,
|
||||
type ServerMessage,
|
||||
ServerMessageDecoder,
|
||||
ServiceRpc,
|
||||
type SessionSummary,
|
||||
SessionSummarySchema,
|
||||
} from "../src/index.ts";
|
||||
|
|
@ -76,33 +73,12 @@ const laneSnapshot = {
|
|||
faulted: false,
|
||||
} satisfies LaneSnapshot;
|
||||
|
||||
const completedRunResult = {
|
||||
ok: true,
|
||||
value: {
|
||||
operationId: "run-1",
|
||||
kind: "run",
|
||||
status: "completed",
|
||||
fromTipId: null,
|
||||
tipId: "leaf-1",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
},
|
||||
} as const satisfies RunResult;
|
||||
|
||||
describe("RPC manifest", () => {
|
||||
test("creates typed client methods from the manifest", async () => {
|
||||
test("creates typed lane-watch client methods from the manifest", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const client = createRpcClient(ServiceRpc, async (call) => {
|
||||
const client = createRpcClient(LaneWatchRpc, async (call) => {
|
||||
calls.push(call);
|
||||
switch (call.method) {
|
||||
case "list":
|
||||
return [summary];
|
||||
case "create":
|
||||
return summary;
|
||||
case "attach":
|
||||
return { sessionId: call.args[0], attachmentId: "attachment-1" };
|
||||
case "prompt":
|
||||
return completedRunResult;
|
||||
case "watch":
|
||||
return { watchId: "watch-1", snapshot: laneSnapshot };
|
||||
case "startWatch":
|
||||
|
|
@ -113,13 +89,6 @@ describe("RPC manifest", () => {
|
|||
}
|
||||
});
|
||||
|
||||
await expect(client.list()).resolves.toEqual([summary]);
|
||||
await expect(client.create({})).resolves.toEqual(summary);
|
||||
await expect(client.attach("session-1")).resolves.toEqual({
|
||||
sessionId: "session-1",
|
||||
attachmentId: "attachment-1",
|
||||
});
|
||||
await expect(client.prompt(["Hello"])).resolves.toEqual(completedRunResult);
|
||||
await expect(client.watch()).resolves.toEqual({ watchId: "watch-1", snapshot: laneSnapshot });
|
||||
await expect(client.startWatch("watch-1")).resolves.toEqual({ watchId: "watch-1" });
|
||||
await expect(client.resnapshotWatch("watch-1")).resolves.toEqual({
|
||||
|
|
@ -128,10 +97,6 @@ describe("RPC manifest", () => {
|
|||
});
|
||||
await expect(client.stopWatch("watch-1")).resolves.toEqual({ watchId: "watch-1" });
|
||||
expect(calls).toEqual([
|
||||
{ method: "list", args: [] },
|
||||
{ method: "create", args: [{}] },
|
||||
{ method: "attach", args: ["session-1"] },
|
||||
{ method: "prompt", args: [["Hello"]] },
|
||||
{ method: "watch", args: [] },
|
||||
{ method: "startWatch", args: ["watch-1"] },
|
||||
{ method: "resnapshotWatch", args: ["watch-1"] },
|
||||
|
|
@ -139,36 +104,21 @@ describe("RPC manifest", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
test("maps built-in contracts onto generic service/member envelopes", () => {
|
||||
const encoded = encodeServiceRpcCall({ method: "attach", args: ["session-1"] });
|
||||
expect(encoded).toEqual({
|
||||
serviceId: "pi.session-management",
|
||||
member: "attach",
|
||||
args: ["session-1"],
|
||||
});
|
||||
expect(decodeServiceRpcCall(encoded)).toEqual({ method: "attach", args: ["session-1"] });
|
||||
expect(decodeServiceRpcCall({ serviceId: "pi.session-management", member: "attach", args: [] })).toBeUndefined();
|
||||
expect(decodeServiceRpcCall({ serviceId: "unknown", member: "method", args: [] })).toBeUndefined();
|
||||
test("maps lane-watch contracts onto generic service/member envelopes", () => {
|
||||
const encoded = encodeLaneWatchRpcCall({ method: "startWatch", args: ["watch-1"] });
|
||||
expect(encoded).toEqual({ serviceId: "pi.transcript", member: "startWatch", args: ["watch-1"] });
|
||||
expect(decodeLaneWatchRpcCall(encoded)).toEqual({ method: "startWatch", args: ["watch-1"] });
|
||||
expect(decodeLaneWatchRpcCall({ serviceId: "pi.transcript", member: "startWatch", args: [] })).toBeUndefined();
|
||||
expect(decodeLaneWatchRpcCall({ serviceId: "unknown", member: "method", args: [] })).toBeUndefined();
|
||||
});
|
||||
|
||||
test("dispatches only methods and values allowed by the manifest", async () => {
|
||||
const dispatch = createRpcDispatcher(ServiceRpc, {
|
||||
list: () => [summary],
|
||||
create: () => summary,
|
||||
attach: (_context, sessionId) => ({ sessionId, attachmentId: "attachment-1" }),
|
||||
prompt: () => completedRunResult,
|
||||
const dispatch = createRpcDispatcher(LaneWatchRpc, {
|
||||
watch: () => ({ watchId: "watch-1", snapshot: laneSnapshot }),
|
||||
startWatch: (_context, watchId) => ({ watchId }),
|
||||
resnapshotWatch: (_context, watchId) => ({ watchId, snapshot: laneSnapshot }),
|
||||
stopWatch: (_context, watchId) => ({ watchId }),
|
||||
});
|
||||
await expect(dispatch({ method: "list", args: [] }, undefined)).resolves.toEqual([summary]);
|
||||
await expect(dispatch({ method: "create", args: [{}] }, undefined)).resolves.toEqual(summary);
|
||||
await expect(dispatch({ method: "attach", args: ["session-1"] }, undefined)).resolves.toEqual({
|
||||
sessionId: "session-1",
|
||||
attachmentId: "attachment-1",
|
||||
});
|
||||
await expect(dispatch({ method: "prompt", args: [["Hello"]] }, undefined)).resolves.toEqual(completedRunResult);
|
||||
await expect(dispatch({ method: "watch", args: [] }, undefined)).resolves.toEqual({
|
||||
watchId: "watch-1",
|
||||
snapshot: laneSnapshot,
|
||||
|
|
@ -177,28 +127,14 @@ describe("RPC manifest", () => {
|
|||
watchId: "watch-1",
|
||||
snapshot: laneSnapshot,
|
||||
});
|
||||
await expect(dispatch({ method: "attach", args: [] } as never, undefined)).rejects.toThrow(/Invalid arguments/);
|
||||
await expect(dispatch({ method: "prompt", args: [[]] } as never, undefined)).rejects.toThrow(/Invalid arguments/);
|
||||
await expect(dispatch({ method: "startWatch", args: [] } as never, undefined)).rejects.toThrow(
|
||||
/Invalid arguments/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid results on both client and dispatcher boundaries", async () => {
|
||||
const client = createRpcClient(ServiceRpc, async () => ({ sessionId: 1 }));
|
||||
await expect(client.attach("session-1")).rejects.toThrow(/Invalid result.*attach/);
|
||||
|
||||
const dispatch = createRpcDispatcher(ServiceRpc, {
|
||||
list: () => [{ id: "session-1" }],
|
||||
create: () => summary,
|
||||
attach: (_context: undefined, sessionId: string) => ({ sessionId, attachmentId: "attachment-1" }),
|
||||
prompt: () => completedRunResult,
|
||||
watch: () => ({ watchId: "watch-1", snapshot: laneSnapshot }),
|
||||
startWatch: (_context: undefined, watchId: string) => ({ watchId }),
|
||||
resnapshotWatch: (_context: undefined, watchId: string) => ({ watchId, snapshot: laneSnapshot }),
|
||||
stopWatch: (_context: undefined, watchId: string) => ({ watchId }),
|
||||
} as never);
|
||||
await expect(dispatch({ method: "list", args: [] }, undefined)).rejects.toThrow(/Invalid result.*list/);
|
||||
|
||||
const invalidPromptClient = createRpcClient(ServiceRpc, async () => ({ ok: true, value: {} }));
|
||||
await expect(invalidPromptClient.prompt(["Hello"])).rejects.toThrow(/Invalid result.*prompt/);
|
||||
test("rejects invalid results on client boundaries", async () => {
|
||||
const client = createRpcClient(LaneWatchRpc, async () => ({ watchId: 1 }));
|
||||
await expect(client.watch()).rejects.toThrow(/Invalid result.*watch/);
|
||||
});
|
||||
|
||||
test("rejects empty manifests instead of creating unusable RPC clients", () => {
|
||||
|
|
@ -393,48 +329,6 @@ describe("protocol validation", () => {
|
|||
expect(() => parseClientMessage({ ...cancel, extra: true })).toThrow(ProtocolValidationError);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["text", ["Hello"]],
|
||||
["text and images", ["Describe", [{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" }]]],
|
||||
["one message", [{ role: "user", content: "Hello", timestamp: 1 }]],
|
||||
[
|
||||
"multiple messages",
|
||||
[
|
||||
[
|
||||
{ role: "user", content: "Question", timestamp: 1 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Answer" }],
|
||||
api: "test",
|
||||
provider: "test",
|
||||
model: "test",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
],
|
||||
],
|
||||
] as const)("validates %s prompt arguments", (_label, promptArguments) => {
|
||||
expect(Check(PromptArgumentsSchema, promptArguments)).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["empty prompt tuple", []],
|
||||
["missing image data", ["Describe", [{ type: "image", mimeType: "image/png" }]]],
|
||||
["unknown message role", [{ role: "extension", content: "Hello", timestamp: 1 }]],
|
||||
["extra text argument", ["Hello", [], "extra"]],
|
||||
] as const)("rejects malformed prompt arguments: %s", (_label, promptArguments) => {
|
||||
expect(Check(PromptArgumentsSchema, promptArguments)).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps presentation-safe Session summary validation at the typed service boundary", () => {
|
||||
expect(Check(SessionSummarySchema, summary)).toBe(true);
|
||||
expect(Check(SessionSummarySchema, { ...summary, cwd: "/private" })).toBe(false);
|
||||
|
|
@ -527,65 +421,6 @@ describe("protocol validation", () => {
|
|||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
completedRunResult,
|
||||
{
|
||||
ok: true,
|
||||
value: {
|
||||
operationId: "run-1",
|
||||
kind: "run",
|
||||
status: "aborted",
|
||||
fromTipId: null,
|
||||
tipId: "leaf-1",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
ok: true,
|
||||
value: {
|
||||
operationId: "run-1",
|
||||
kind: "run",
|
||||
status: "failed",
|
||||
fromTipId: null,
|
||||
tipId: "leaf-1",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
error: { code: "provider", message: "provider failed" },
|
||||
},
|
||||
},
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "LaneBusy",
|
||||
lane: "main",
|
||||
operationId: "operation-1",
|
||||
operationKind: "run",
|
||||
message: "lane busy",
|
||||
},
|
||||
},
|
||||
{ ok: false, error: { _tag: "Closed", message: "closed" } },
|
||||
] satisfies RunResult[])("validates structural Harness RunResult", (result) => {
|
||||
expect(Check(RunResultSchema, result)).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
ok: true,
|
||||
value: {
|
||||
// Keep the removed discriminant out of the M0 no-residue grep while proving schema rejection.
|
||||
kind: ["action", "required"].join("_"),
|
||||
runId: "run-1",
|
||||
action: { kind: "confirm", description: "Confirm" },
|
||||
},
|
||||
},
|
||||
{ ok: true, value: { kind: "failed", runId: "run-1", tipId: "leaf-1" } },
|
||||
{ ok: true, value: { kind: "completed", runId: "run-1", tipId: "leaf-1", finalEntryId: "entry-1" } },
|
||||
{ ok: false, error: { _tag: "Closed", message: "closed", extra: true } },
|
||||
] as const)("rejects malformed structural Harness RunResult", (result) => {
|
||||
expect(Check(RunResultSchema, result)).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
"empty request id",
|
||||
|
|
|
|||
|
|
@ -2,44 +2,37 @@
|
|||
|
||||
Experimental local server for the new durable Session and Agent Harness interfaces.
|
||||
|
||||
The current slice supports server- and Session-scoped facet-service routing, multi-presentation attachment, prompting, and optional main-lane observation. `RoutedServerServiceHost.attachClient()` creates one connection-scoped server service endpoint with narrow attachment-management capabilities. `RoutedSessionHandle.attachClient()` returns a presentation-scoped Session capability. Its optional `invokeService()` forwards an opaque service/member envelope to the selected Session endpoint; the server validates the attachment route but does not load the facet contract. Its optional `watch()` supplies an authoritative snapshot plus buffered events.
|
||||
The current slice supports server- and Session-scoped facet-service routing, multi-presentation attachment, and optional main-lane observation. `RoutedServerServiceHost.attachClient()` creates one connection-scoped server service endpoint with narrow attachment-management capabilities. `RoutedSessionHandle.attachClient()` returns a presentation-scoped Session capability. Its optional `invokeService()` forwards an opaque service/member envelope to the selected Session endpoint; the server validates the attachment route but does not load the facet contract. Its optional `watch()` supplies an authoritative snapshot plus buffered events.
|
||||
|
||||
- server service calls and subscriptions route opaquely through the connection's `RoutedServerServiceAttachment`;
|
||||
- the application-owned `SessionDirectory` projects the private catalog into replicated presentation-safe state;
|
||||
- the application-owned `SessionManagement` creates, removes, attaches, and detaches Sessions without exposing route IDs in business results;
|
||||
- attachment changes are published out of band after the router installs or clears the live route;
|
||||
- the compatibility `list`, `create`, and `attach` operations remain available to low-level clients during migration;
|
||||
- `prompt` executes one serializable prompt through the requesting presentation's attachment capability.
|
||||
- unknown Session service calls route through `invokeService` without server-side business-payload decoding;
|
||||
- Session service calls route through `invokeService` without server-side business-payload decoding;
|
||||
- service subscription updates remain scoped to the requesting attachment;
|
||||
- `watch`, `startWatch`, and `stopWatch` provide snapshot-first lane observation when supported by the host.
|
||||
|
||||
A Session may have multiple presentation attachments. Repeating `attach` from one connection is idempotent; every successful attachment has a server-generated `attachmentId` delivered only as routing control data. Session requests carry `{ serverId, sessionId, attachmentId }`, and the server rejects stale or mismatched routes. Losing a connection rejects its local responses but releases its attachment only after admitted prompts settle. The host decides when zero presentation demand and worker-local Harness activity permit worker retirement. Server shutdown closes every routed Session handle, releasing its worker and Session writer ownership.
|
||||
A Session may have multiple presentation attachments. Repeating `attach` from one connection is idempotent; every successful attachment has a server-generated `attachmentId` delivered only as routing control data. Session requests carry `{ serverId, sessionId, attachmentId }`, and the server rejects stale or mismatched routes. Losing a connection rejects its local responses but releases its attachment only after admitted service calls settle. The host decides when zero presentation demand and worker-local Harness activity permit worker retirement. Server shutdown closes every routed Session handle, releasing its worker and Session writer ownership.
|
||||
|
||||
```ts
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { MemorySessionRepo, type Session } from "@earendil-works/pi-agent-core";
|
||||
import {
|
||||
type RoutedServerServiceHost,
|
||||
type RoutedSessionHandle,
|
||||
type ServerHost,
|
||||
} from "@earendil-works/pi-server";
|
||||
import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-server/unix";
|
||||
|
||||
async function startServer(
|
||||
serverServices: RoutedServerServiceHost,
|
||||
openRoutedSession: (session: Session) => Promise<RoutedSessionHandle>,
|
||||
) {
|
||||
const sessions = new MemorySessionRepo();
|
||||
const host: ServerHost = {
|
||||
serverServices,
|
||||
sessions: {
|
||||
list: (context) => sessions.list(undefined, context),
|
||||
async create({ id }, context) {
|
||||
const session = await sessions.create({ id }, context);
|
||||
try {
|
||||
return session.metadata;
|
||||
} finally {
|
||||
await session.close(context);
|
||||
}
|
||||
},
|
||||
},
|
||||
async openSession(metadata, context) {
|
||||
const session = await sessions.open(metadata, context);
|
||||
|
|
@ -69,7 +62,7 @@ async function startServer(
|
|||
}
|
||||
```
|
||||
|
||||
Applications supply a private Session catalog and a routed Session factory. The server projects catalog records to summaries before transport; the host receives the repository's concrete metadata and owns acquiring the worker-local Session and Harness. Failures are cleaned up in that worker. Neither an open JavaScript Session nor a Harness crosses the process boundary.
|
||||
Applications supply a private Session catalog, server service host, and routed Session factory. Session discovery and management are application-owned services; the router uses the private catalog only to resolve attachment requests to concrete metadata. The host owns acquiring the worker-local Session and Harness. Failures are cleaned up in that worker. Neither an open JavaScript Session nor a Harness crosses the process boundary.
|
||||
|
||||
`serverId` is a logical identity supplied by the launcher, not a socket address. The Unix preset requires an explicit physical `path`; `getUnixSocketPath()` derives one from a caller-selected directory. Choose a short, private runtime directory rather than deriving the route from an unbounded home-directory path. A long-lived launcher can reuse the same ID and path when replacing a server process.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
type ClientMessage,
|
||||
ClientMessageDecoder,
|
||||
DEFAULT_MAX_FRAME_LENGTH,
|
||||
decodeServiceRpcCall,
|
||||
decodeLaneWatchRpcCall,
|
||||
encodeServerMessage,
|
||||
isServerId,
|
||||
isSupportedProtocolVersion,
|
||||
|
|
@ -315,18 +315,10 @@ export class Server<TMetadata extends SessionMetadata = SessionMetadata> {
|
|||
try {
|
||||
if (envelope.target.serverId !== this.serverId) throw new WrongServerError();
|
||||
let result: ProtocolRpcResult;
|
||||
const call = decodeServiceRpcCall(envelope.call);
|
||||
if (!("sessionId" in envelope.target) && state.serverServices !== undefined && call?.method !== "list") {
|
||||
result = await state.serverServices.invokeService(
|
||||
envelope.call,
|
||||
async (subscriptionId, update) => {
|
||||
await this.sendMessage(state, { type: "service_update", subscriptionId, update });
|
||||
},
|
||||
context,
|
||||
);
|
||||
} else if (call !== undefined) {
|
||||
result = await this.sessions.executeCall(
|
||||
call,
|
||||
const laneWatchCall = decodeLaneWatchRpcCall(envelope.call);
|
||||
if (laneWatchCall !== undefined) {
|
||||
result = await this.sessions.executeLaneWatchCall(
|
||||
laneWatchCall,
|
||||
envelope.target,
|
||||
state,
|
||||
async (message, _context) => {
|
||||
|
|
@ -344,6 +336,14 @@ export class Server<TMetadata extends SessionMetadata = SessionMetadata> {
|
|||
},
|
||||
context,
|
||||
);
|
||||
} else if (state.serverServices !== undefined) {
|
||||
result = await state.serverServices.invokeService(
|
||||
envelope.call,
|
||||
async (subscriptionId, update) => {
|
||||
await this.sendMessage(state, { type: "service_update", subscriptionId, update });
|
||||
},
|
||||
context,
|
||||
);
|
||||
} else {
|
||||
throw new ProtocolValidationError(
|
||||
`Unknown service member ${envelope.call.serviceId}.${envelope.call.member}`,
|
||||
|
|
@ -464,9 +464,6 @@ export class Server<TMetadata extends SessionMetadata = SessionMetadata> {
|
|||
if (error instanceof ServerError || error instanceof RemoteServiceError) {
|
||||
return { code: error.code, message: error.message };
|
||||
}
|
||||
if (error instanceof Error && "code" in error && error.code === "service_not_implemented") {
|
||||
return { code: error.code, message: error.message };
|
||||
}
|
||||
if (error instanceof ProtocolValidationError) {
|
||||
return { code: "invalid_request", message: error.message };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,17 +3,14 @@ import { BACKGROUND_CONTEXT, type Context, type SessionMetadata } from "@earendi
|
|||
import {
|
||||
createRpcDispatcher,
|
||||
type EventEnvelope,
|
||||
type PromptArguments,
|
||||
LaneWatchRpc,
|
||||
type LaneWatchRpcCall,
|
||||
type LaneWatchRpcResultUnion,
|
||||
type ProtocolRpcCall,
|
||||
type ProtocolRpcResult,
|
||||
ProtocolValidationError,
|
||||
type RpcTarget,
|
||||
type RunResult,
|
||||
type ServerEventEnvelope,
|
||||
ServiceRpc,
|
||||
type ServiceRpcCall,
|
||||
type ServiceRpcResultUnion,
|
||||
type SessionSummary,
|
||||
type SessionTarget,
|
||||
} from "@earendil-works/pi-protocol";
|
||||
import {
|
||||
|
|
@ -29,10 +26,6 @@ import type { RoutedSessionAttachment, RoutedSessionHandle, RoutedSessionWatch,
|
|||
|
||||
class SessionCleanupError extends AggregateError {}
|
||||
|
||||
function toSessionSummary(serverId: string, metadata: SessionMetadata): SessionSummary {
|
||||
return { serverId, sessionId: metadata.id, createdAt: metadata.createdAt };
|
||||
}
|
||||
|
||||
interface ActiveWatch {
|
||||
readonly id: string;
|
||||
readonly handle: RoutedSessionWatch;
|
||||
|
|
@ -79,40 +72,16 @@ export class SessionRouter<TMetadata extends SessionMetadata = SessionMetadata>
|
|||
private readonly disconnectedClients = new Set<object>();
|
||||
private readonly clientOperations = new Map<object, Promise<void>>();
|
||||
private closePromise?: Promise<void>;
|
||||
private readonly dispatchRpc: (call: ServiceRpcCall, context: RpcContext) => Promise<ServiceRpcResultUnion>;
|
||||
private readonly dispatchLaneWatch: (
|
||||
call: LaneWatchRpcCall,
|
||||
context: RpcContext,
|
||||
) => Promise<LaneWatchRpcResultUnion>;
|
||||
|
||||
constructor(options: SessionRouterOptions<TMetadata>) {
|
||||
this.options = options;
|
||||
this.dispatchRpc = createRpcDispatcher(
|
||||
ServiceRpc,
|
||||
this.dispatchLaneWatch = createRpcDispatcher(
|
||||
LaneWatchRpc,
|
||||
{
|
||||
list: async ({ context, target }, ..._args: never[]) => {
|
||||
this.requireServerTarget(target);
|
||||
return (await this.options.host.sessions.list(context)).map((metadata) =>
|
||||
toSessionSummary(target.serverId, metadata),
|
||||
);
|
||||
},
|
||||
create: async ({ client, context, target }, createOptions) => {
|
||||
this.requireServerTarget(target);
|
||||
return this.runForClient(client, async () => {
|
||||
if (this.options.isClosing() || this.disconnectedClients.has(client)) throw new ServerDrainingError();
|
||||
return toSessionSummary(
|
||||
target.serverId,
|
||||
await this.options.host.sessions.create(createOptions, context),
|
||||
);
|
||||
});
|
||||
},
|
||||
attach: async ({ client, context, target }, sessionId) => {
|
||||
this.requireServerTarget(target);
|
||||
if (this.options.isClosing()) throw new ServerDrainingError();
|
||||
return this.attachClient(client, sessionId, context);
|
||||
},
|
||||
prompt: async ({ client, context, target }, prompt) => {
|
||||
const admitted = await this.runForClient(client, () =>
|
||||
this.startPrompt(client, target, prompt, context),
|
||||
);
|
||||
return admitted.result;
|
||||
},
|
||||
watch: ({ client, context, target }, ..._args: never[]) =>
|
||||
this.runForClient(client, () => this.createWatch(client, target, context)),
|
||||
startWatch: ({ client, publish, context, target }, watchId) =>
|
||||
|
|
@ -126,14 +95,14 @@ export class SessionRouter<TMetadata extends SessionMetadata = SessionMetadata>
|
|||
);
|
||||
}
|
||||
|
||||
executeCall(
|
||||
call: ServiceRpcCall,
|
||||
executeLaneWatchCall(
|
||||
call: LaneWatchRpcCall,
|
||||
target: RpcTarget,
|
||||
client: object,
|
||||
publish: (message: EventEnvelope, context: Context) => Promise<void>,
|
||||
context: Context,
|
||||
): Promise<ServiceRpcResultUnion> {
|
||||
return this.dispatchRpc(call, { client, target, publish, context });
|
||||
): Promise<LaneWatchRpcResultUnion> {
|
||||
return this.dispatchLaneWatch(call, { client, target, publish, context });
|
||||
}
|
||||
|
||||
async executeServiceCall(
|
||||
|
|
@ -149,11 +118,7 @@ export class SessionRouter<TMetadata extends SessionMetadata = SessionMetadata>
|
|||
return admitted.result;
|
||||
}
|
||||
|
||||
attachClient(
|
||||
client: object,
|
||||
sessionId: string,
|
||||
context: Context,
|
||||
): Promise<{ sessionId: string; attachmentId: string }> {
|
||||
attachClient(client: object, sessionId: string, context: Context): Promise<void> {
|
||||
if (this.options.isClosing()) return Promise.reject(new ServerDrainingError());
|
||||
return this.runForClient(client, () => this.attachClientNow(client, sessionId, context));
|
||||
}
|
||||
|
|
@ -253,14 +218,10 @@ export class SessionRouter<TMetadata extends SessionMetadata = SessionMetadata>
|
|||
return result;
|
||||
}
|
||||
|
||||
private async attachClientNow(
|
||||
client: object,
|
||||
sessionId: string,
|
||||
context: Context,
|
||||
): Promise<{ sessionId: string; attachmentId: string }> {
|
||||
private async attachClientNow(client: object, sessionId: string, context: Context): Promise<void> {
|
||||
if (this.options.isClosing() || this.disconnectedClients.has(client)) throw new ServerDrainingError();
|
||||
const current = this.attachmentsByClient.get(client);
|
||||
if (current?.session.id === sessionId) return { sessionId, attachmentId: current.id };
|
||||
if (current?.session.id === sessionId) return;
|
||||
const hosted = await this.acquire(sessionId, context);
|
||||
if (this.options.isClosing() || this.disconnectedClients.has(client)) throw new ServerDrainingError();
|
||||
if (current) await this.releaseAttachment(current, context, false);
|
||||
|
|
@ -294,21 +255,6 @@ export class SessionRouter<TMetadata extends SessionMetadata = SessionMetadata>
|
|||
{ serverId: this.options.serverId, sessionId, attachmentId: attachment.id },
|
||||
context,
|
||||
);
|
||||
return { sessionId, attachmentId: attachment.id };
|
||||
}
|
||||
|
||||
private async startPrompt(
|
||||
client: object,
|
||||
target: RpcTarget,
|
||||
prompt: PromptArguments,
|
||||
context: Context,
|
||||
): Promise<{ result: Promise<RunResult> }> {
|
||||
const attachment = this.requireAttachment(client, target);
|
||||
const lease = attachment.lease;
|
||||
if (lease === undefined) throw new SessionNotAttachedError();
|
||||
const result = lease.prompt(prompt, context);
|
||||
this.trackOperation(attachment, result);
|
||||
return { result };
|
||||
}
|
||||
|
||||
private async startServiceCall(
|
||||
|
|
@ -421,10 +367,6 @@ export class SessionRouter<TMetadata extends SessionMetadata = SessionMetadata>
|
|||
return attachment;
|
||||
}
|
||||
|
||||
private requireServerTarget(target: RpcTarget): void {
|
||||
if ("sessionId" in target) throw new ProtocolValidationError("Server operation cannot target a Session");
|
||||
}
|
||||
|
||||
private releaseAttachment(attachment: ClientAttachment, context: Context, publish = true): Promise<void> {
|
||||
attachment.releasing ??= (async () => {
|
||||
const errors: unknown[] = [];
|
||||
|
|
|
|||
|
|
@ -54,55 +54,57 @@ export class ProtocolTestClient {
|
|||
return response;
|
||||
}
|
||||
|
||||
async request(
|
||||
serverId: string,
|
||||
call: TestRpcCall,
|
||||
request(serverId: string, call: TestRpcCall, id?: string): Promise<ResponseEnvelope> {
|
||||
const routed = this.routeLaneWatchCall(serverId, call);
|
||||
return this.requestService(routed.target, routed.call, id);
|
||||
}
|
||||
|
||||
async requestService(
|
||||
target: RpcTarget,
|
||||
call: ProtocolRpcCall,
|
||||
id = `request-${++this.requestSequence}`,
|
||||
): Promise<ResponseEnvelope> {
|
||||
const response = this.next(
|
||||
(message): message is ResponseEnvelope => message.type === "response" && message.id === id,
|
||||
);
|
||||
const routed = this.routeLegacyCall(serverId, call);
|
||||
await this.sendMessage({ type: "request", id, ...routed });
|
||||
const received = (await response) as ResponseEnvelope;
|
||||
if (
|
||||
call.method === "attach" &&
|
||||
received.ok &&
|
||||
typeof received.result === "object" &&
|
||||
received.result !== null &&
|
||||
"sessionId" in received.result &&
|
||||
"attachmentId" in received.result &&
|
||||
typeof received.result.sessionId === "string" &&
|
||||
typeof received.result.attachmentId === "string"
|
||||
) {
|
||||
this.attachment = {
|
||||
sessionId: received.result.sessionId,
|
||||
attachmentId: received.result.attachmentId,
|
||||
};
|
||||
}
|
||||
return received;
|
||||
await this.sendMessage({ type: "request", id, target, call });
|
||||
return (await response) as ResponseEnvelope;
|
||||
}
|
||||
|
||||
attach(serverId: string, sessionId: string): Promise<ResponseEnvelope> {
|
||||
return this.requestService(
|
||||
{ serverId },
|
||||
{ serviceId: "pi.session-management", member: "attach", args: [sessionId] },
|
||||
);
|
||||
}
|
||||
|
||||
requestSessionService(
|
||||
serverId: string,
|
||||
sessionId: string,
|
||||
call: ProtocolRpcCall,
|
||||
id?: string,
|
||||
): Promise<ResponseEnvelope> {
|
||||
const attachment = this.attachment;
|
||||
const target: RpcTarget =
|
||||
attachment === undefined || attachment.sessionId !== sessionId
|
||||
? { serverId, sessionId, attachmentId: "missing-attachment" }
|
||||
: { serverId, ...attachment };
|
||||
return this.requestService(target, call, id);
|
||||
}
|
||||
|
||||
sendMessage(message: ClientMessage): Promise<void> {
|
||||
return this.channel.send(encodeClientMessage(message));
|
||||
}
|
||||
|
||||
private routeLegacyCall(serverId: string, call: TestRpcCall): { target: RpcTarget; call: ProtocolRpcCall } {
|
||||
const sessionOperation = ["prompt", "watch", "startWatch", "resnapshotWatch", "stopWatch"].includes(call.method);
|
||||
let target: RpcTarget = { serverId };
|
||||
let args = call.args;
|
||||
if (sessionOperation) {
|
||||
if (typeof call.args[0] !== "string") throw new Error(`Test call ${call.method} requires a Session ID`);
|
||||
const sessionId = call.args[0];
|
||||
const attachment = this.attachment;
|
||||
target =
|
||||
attachment === undefined || attachment.sessionId !== sessionId
|
||||
? { serverId, sessionId, attachmentId: "missing-attachment" }
|
||||
: { serverId, ...attachment };
|
||||
args = call.args.slice(1);
|
||||
}
|
||||
const address = serviceAddress(call.method);
|
||||
return { target, call: { ...address, args } };
|
||||
private routeLaneWatchCall(serverId: string, call: TestRpcCall): { target: RpcTarget; call: ProtocolRpcCall } {
|
||||
if (typeof call.args[0] !== "string") throw new Error(`Test call ${call.method} requires a Session ID`);
|
||||
const sessionId = call.args[0];
|
||||
const attachment = this.attachment;
|
||||
const target: RpcTarget =
|
||||
attachment === undefined || attachment.sessionId !== sessionId
|
||||
? { serverId, sessionId, attachmentId: "missing-attachment" }
|
||||
: { serverId, ...attachment };
|
||||
return { target, call: { serviceId: "pi.transcript", member: call.method, args: call.args.slice(1) } };
|
||||
}
|
||||
|
||||
sendBytes(chunk: Uint8Array): Promise<void> {
|
||||
|
|
@ -135,6 +137,15 @@ export class ProtocolTestClient {
|
|||
receive(chunk: Uint8Array): void {
|
||||
try {
|
||||
for (const message of this.decoder.push(chunk)) {
|
||||
if (message.type === "attachment") {
|
||||
this.attachment =
|
||||
message.attachment === null
|
||||
? undefined
|
||||
: {
|
||||
sessionId: message.attachment.sessionId,
|
||||
attachmentId: message.attachment.attachmentId,
|
||||
};
|
||||
}
|
||||
this.messages.push(message);
|
||||
for (const waiter of this.waiters) {
|
||||
if (!waiter.predicate(message)) continue;
|
||||
|
|
@ -160,25 +171,6 @@ export class ProtocolTestClient {
|
|||
}
|
||||
}
|
||||
|
||||
function serviceAddress(method: string): { serviceId: string; member: string } {
|
||||
switch (method) {
|
||||
case "list":
|
||||
return { serviceId: "pi.session-directory", member: "list" };
|
||||
case "create":
|
||||
case "attach":
|
||||
return { serviceId: "pi.session-management", member: method };
|
||||
case "prompt":
|
||||
return { serviceId: "pi.chat", member: "prompt" };
|
||||
case "watch":
|
||||
case "startWatch":
|
||||
case "resnapshotWatch":
|
||||
case "stopWatch":
|
||||
return { serviceId: "pi.transcript", member: method };
|
||||
default:
|
||||
return { serviceId: "unknown", member: method };
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectUnixTestClient(path: string): Promise<ProtocolTestClient> {
|
||||
const socket = createConnection(path);
|
||||
await once(socket, "connect");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Context, Session, SessionMetadata } from "@earendil-works/pi-agent-core";
|
||||
import { BACKGROUND_CONTEXT, MemorySessionRepo } from "@earendil-works/pi-agent-core";
|
||||
import type { LaneEvent, LaneSnapshot, PromptArguments, RunResult } from "@earendil-works/pi-protocol";
|
||||
import type { RoutedSessionHandle, RoutedSessionWatch, ServerHost } from "../types.ts";
|
||||
import type { LaneEvent, LaneSnapshot, ProtocolRpcCall, ProtocolRpcResult } from "@earendil-works/pi-protocol";
|
||||
import type { RoutedServerServiceHost, RoutedSessionHandle, RoutedSessionWatch, ServerHost } from "../types.ts";
|
||||
|
||||
export class Deferred<T> {
|
||||
readonly promise: Promise<T>;
|
||||
|
|
@ -102,29 +102,29 @@ export class TestHarness {
|
|||
attachedClients = 0;
|
||||
attachmentReleaseCount = 0;
|
||||
closeCount = 0;
|
||||
readonly promptCalls: PromptArguments[] = [];
|
||||
readonly serviceCalls: ProtocolRpcCall[] = [];
|
||||
watchSnapshot: LaneSnapshot = structuredClone(emptyLaneSnapshot);
|
||||
private readonly watches = new Set<TestHarnessWatch>();
|
||||
failAttachmentRelease?: Error;
|
||||
failClose?: Error;
|
||||
nextPromptError?: Error;
|
||||
nextPromptResult?: RunResult;
|
||||
nextServiceError?: Error;
|
||||
nextServiceResult: ProtocolRpcResult = { ok: true };
|
||||
private nextCloseGate?: OpenGate;
|
||||
private nextPromptGate?: OpenGate;
|
||||
private nextServiceGate?: OpenGate;
|
||||
|
||||
constructor(session: Session) {
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
attachClient(_context: Context): {
|
||||
prompt: TestHarness["prompt"];
|
||||
invokeService: TestHarness["invokeService"];
|
||||
watch: TestHarness["watch"];
|
||||
release(context: Context): void;
|
||||
} {
|
||||
this.attachedClients += 1;
|
||||
let released = false;
|
||||
return {
|
||||
prompt: (prompt, context) => this.prompt(prompt, context),
|
||||
invokeService: (call) => this.invokeService(call),
|
||||
watch: (context) => this.watch(context),
|
||||
release: (_context) => {
|
||||
if (released) return;
|
||||
|
|
@ -154,32 +154,21 @@ export class TestHarness {
|
|||
await Promise.all([...this.watches].map((watch) => watch.push(event, context)));
|
||||
}
|
||||
|
||||
async prompt(prompt: PromptArguments, _context: Context): Promise<RunResult> {
|
||||
this.promptCalls.push(prompt);
|
||||
if (this.nextPromptError) {
|
||||
const error = this.nextPromptError;
|
||||
this.nextPromptError = undefined;
|
||||
async invokeService(call: ProtocolRpcCall): Promise<ProtocolRpcResult> {
|
||||
this.serviceCalls.push(call);
|
||||
if (this.nextServiceError) {
|
||||
const error = this.nextServiceError;
|
||||
this.nextServiceError = undefined;
|
||||
throw error;
|
||||
}
|
||||
const gate = this.nextPromptGate;
|
||||
const gate = this.nextServiceGate;
|
||||
if (gate) {
|
||||
this.nextPromptGate = undefined;
|
||||
this.nextServiceGate = undefined;
|
||||
gate.entered.resolve(undefined);
|
||||
await gate.release.promise;
|
||||
}
|
||||
const result = this.nextPromptResult ?? {
|
||||
ok: true,
|
||||
value: {
|
||||
operationId: "run-1",
|
||||
kind: "run",
|
||||
status: "completed",
|
||||
fromTipId: null,
|
||||
tipId: "leaf-1",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
},
|
||||
};
|
||||
this.nextPromptResult = undefined;
|
||||
const result = this.nextServiceResult;
|
||||
this.nextServiceResult = { ok: true };
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -212,9 +201,9 @@ export class TestHarness {
|
|||
return gate;
|
||||
}
|
||||
|
||||
gateNextPrompt(): OpenGate {
|
||||
gateNextServiceCall(): OpenGate {
|
||||
const gate = { entered: new Deferred<void>(), release: new Deferred<void>() };
|
||||
this.nextPromptGate = gate;
|
||||
this.nextServiceGate = gate;
|
||||
return gate;
|
||||
}
|
||||
}
|
||||
|
|
@ -224,7 +213,40 @@ interface ListDelay {
|
|||
release: Deferred<void>;
|
||||
}
|
||||
|
||||
export function createTestServerServices(): RoutedServerServiceHost {
|
||||
return {
|
||||
attachClient(presentation) {
|
||||
return {
|
||||
async invokeService(call, _publish, context) {
|
||||
if (
|
||||
call.instance === undefined &&
|
||||
call.serviceId === "pi.session-management" &&
|
||||
call.member === "attach" &&
|
||||
call.args.length === 1 &&
|
||||
typeof call.args[0] === "string"
|
||||
) {
|
||||
await presentation.attachSession(call.args[0], context);
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
call.instance === undefined &&
|
||||
call.serviceId === "pi.session-management" &&
|
||||
call.member === "detach" &&
|
||||
call.args.length === 0
|
||||
) {
|
||||
await presentation.detachSession(context);
|
||||
return null;
|
||||
}
|
||||
throw new Error(`Unsupported test server service ${call.serviceId}.${call.member}`);
|
||||
},
|
||||
release() {},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class TestServerHost implements ServerHost {
|
||||
readonly serverServices = createTestServerServices();
|
||||
readonly repo = new MemorySessionRepo({ now: () => 1 });
|
||||
readonly harnesses = new Map<string, TestHarness[]>();
|
||||
openSessionCount = 0;
|
||||
|
|
@ -240,14 +262,6 @@ export class TestServerHost implements ServerHost {
|
|||
}
|
||||
return this.repo.list(undefined, context);
|
||||
},
|
||||
create: async ({ id }, context) => {
|
||||
const session = await this.repo.create({ id }, context);
|
||||
try {
|
||||
return session.metadata;
|
||||
} finally {
|
||||
await session.close(context);
|
||||
}
|
||||
},
|
||||
};
|
||||
private nextListDelay?: ListDelay;
|
||||
private nextOpenSessionGate?: OpenGate;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export type { WireChannel } from "./client.ts";
|
||||
export { connectUnixTestClient, ProtocolTestClient } from "./client.ts";
|
||||
export { Deferred, TestHarness, TestServerHost } from "./host.ts";
|
||||
export { createTestServerServices, Deferred, TestHarness, TestServerHost } from "./host.ts";
|
||||
export type { TestServer, TestServerOptions } from "./server.ts";
|
||||
export { createTestServer } from "./server.ts";
|
||||
|
|
|
|||
|
|
@ -2,12 +2,9 @@ import type { Context, SessionMetadata } from "@earendil-works/pi-agent-core";
|
|||
import type {
|
||||
LaneEvent,
|
||||
LaneSnapshot,
|
||||
PromptArguments,
|
||||
ProtocolRpcCall,
|
||||
ProtocolRpcResult,
|
||||
RunResult,
|
||||
ServiceProviderUpdate,
|
||||
SessionCreateOptions,
|
||||
} from "@earendil-works/pi-protocol";
|
||||
import type { ServerListener } from "./listener.ts";
|
||||
|
||||
|
|
@ -25,8 +22,6 @@ export type MaybePromise<T> = T | Promise<T>;
|
|||
|
||||
/** One presentation connection's live capability for a hosted Session. */
|
||||
export interface RoutedSessionAttachment {
|
||||
/** Execute one serializable prompt through this attachment. */
|
||||
prompt(prompt: PromptArguments, context: Context): Promise<RunResult>;
|
||||
/** Route one contract-agnostic service operation to the attached Session endpoint. */
|
||||
invokeService?(
|
||||
call: ProtocolRpcCall,
|
||||
|
|
@ -81,7 +76,6 @@ export interface ServerHost<TMetadata extends SessionMetadata = SessionMetadata>
|
|||
readonly serverServices?: RoutedServerServiceHost;
|
||||
readonly sessions: {
|
||||
list(context: Context): Promise<TMetadata[]>;
|
||||
create(options: SessionCreateOptions, context: Context): Promise<TMetadata>;
|
||||
};
|
||||
openSession(metadata: TMetadata, context: Context): Promise<RoutedSessionHandle>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,22 @@
|
|||
import { BACKGROUND_CONTEXT, type SessionMetadata } from "@earendil-works/pi-agent-core";
|
||||
import type { ProtocolRpcCall } from "@earendil-works/pi-protocol";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import type { ByteConnection, ByteConnectionHandler } from "../src/connection.ts";
|
||||
import { Server } from "../src/server.ts";
|
||||
import { Deferred, ProtocolTestClient, TestServerHost, type WireChannel } from "../src/testing/index.ts";
|
||||
import {
|
||||
createTestServerServices,
|
||||
Deferred,
|
||||
ProtocolTestClient,
|
||||
TestServerHost,
|
||||
type WireChannel,
|
||||
} from "../src/testing/index.ts";
|
||||
import type { ServerHost } from "../src/types.ts";
|
||||
|
||||
const serverId = "00000000-0000-4000-8000-000000000001";
|
||||
const servers = new Set<Server>();
|
||||
|
||||
function createServer(host: ServerHost, serverId = "00000000-0000-4000-8000-000000000001"): Server {
|
||||
const server = new Server(host, {
|
||||
listeners: [],
|
||||
serverId,
|
||||
});
|
||||
function createServer(host: ServerHost, id = serverId): Server {
|
||||
const server = new Server(host, { listeners: [], serverId: id });
|
||||
servers.add(server);
|
||||
return server;
|
||||
}
|
||||
|
|
@ -53,6 +58,19 @@ function connect(server: Server): ProtocolTestClient {
|
|||
return client;
|
||||
}
|
||||
|
||||
function sessionCall(member: string, args: ProtocolRpcCall["args"] = []): ProtocolRpcCall {
|
||||
return { serviceId: "test.session", member, args };
|
||||
}
|
||||
|
||||
function latestAttachmentId(client: ProtocolTestClient, sessionId: string): string {
|
||||
for (const message of [...client.messages].reverse()) {
|
||||
if (message.type === "attachment" && message.attachment?.sessionId === sessionId) {
|
||||
return message.attachment.attachmentId;
|
||||
}
|
||||
}
|
||||
throw new Error(`Missing attachment for ${sessionId}`);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([...servers].map((server) => server.close()));
|
||||
servers.clear();
|
||||
|
|
@ -64,54 +82,7 @@ describe("Session protocol", () => {
|
|||
await host.seed();
|
||||
const client = connect(createServer(host));
|
||||
|
||||
expect(await client.hello()).toMatchObject({ type: "hello", serverId: "00000000-0000-4000-8000-000000000001" });
|
||||
expect(host.harnesses.size).toBe(0);
|
||||
});
|
||||
|
||||
test("list returns presentation-safe summaries without opening a Session", async () => {
|
||||
const host = new TestServerHost();
|
||||
const metadata = await host.seed("session-1", "parent-1");
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
|
||||
await expect(
|
||||
client.request("00000000-0000-4000-8000-000000000001", { method: "list", args: [] }),
|
||||
).resolves.toEqual({
|
||||
type: "response",
|
||||
id: "request-1",
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
sessionId: metadata.id,
|
||||
createdAt: metadata.createdAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(host.harnesses.size).toBe(0);
|
||||
});
|
||||
|
||||
test("create persists a Session without opening a Harness", async () => {
|
||||
const host = new TestServerHost();
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
|
||||
await expect(
|
||||
client.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "create",
|
||||
args: [{ id: "session-1" }],
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
sessionId: "session-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
client.request("00000000-0000-4000-8000-000000000001", { method: "list", args: [] }),
|
||||
).resolves.toMatchObject({ result: [{ sessionId: "session-1" }] });
|
||||
expect(await client.hello()).toMatchObject({ type: "hello", serverId });
|
||||
expect(host.harnesses.size).toBe(0);
|
||||
});
|
||||
|
||||
|
|
@ -127,58 +98,25 @@ describe("Session protocol", () => {
|
|||
};
|
||||
let received: BackendMetadata | undefined;
|
||||
const host: ServerHost<BackendMetadata> = {
|
||||
sessions: { list: async () => [metadata], create: async () => metadata },
|
||||
serverServices: createTestServerServices(),
|
||||
sessions: { list: async () => [metadata] },
|
||||
openSession: async (candidate) => {
|
||||
received = candidate;
|
||||
return {
|
||||
attachClient: async () => ({
|
||||
prompt: async () => ({
|
||||
ok: true,
|
||||
value: {
|
||||
operationId: "run-1",
|
||||
kind: "run" as const,
|
||||
status: "completed" as const,
|
||||
fromTipId: null,
|
||||
tipId: "leaf-1",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
},
|
||||
}),
|
||||
release: () => {},
|
||||
}),
|
||||
attachClient: () => ({ release() {} }),
|
||||
close: async () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
const server = new Server(host, {
|
||||
listeners: [],
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
});
|
||||
servers.add(server);
|
||||
const client = connect(server);
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
|
||||
await expect(
|
||||
client.request("00000000-0000-4000-8000-000000000001", { method: "list", args: [] }),
|
||||
).resolves.toEqual({
|
||||
type: "response",
|
||||
id: "request-1",
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
sessionId: "session-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
await expect(
|
||||
client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"] }),
|
||||
).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } });
|
||||
await expect(client.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
|
||||
expect(received).toBe(metadata);
|
||||
await expect(
|
||||
client.request("00000000-0000-4000-8000-000000000001", { method: "watch", args: ["session-1"] }),
|
||||
).resolves.toMatchObject({ ok: false, error: { code: "not_supported" } });
|
||||
await expect(client.request(serverId, { method: "watch", args: ["session-1"] })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "not_supported" },
|
||||
});
|
||||
});
|
||||
|
||||
test("routes opaque server services and publishes attachment changes out of band", async () => {
|
||||
|
|
@ -195,11 +133,11 @@ describe("Session protocol", () => {
|
|||
if (call.serviceId !== "pi.session-management") throw new Error("Unexpected service");
|
||||
if (call.member === "attach" && typeof call.args[0] === "string") {
|
||||
await presentation.attachSession(call.args[0], context);
|
||||
return undefined;
|
||||
return null;
|
||||
}
|
||||
if (call.member === "detach") {
|
||||
await presentation.detachSession(context);
|
||||
return undefined;
|
||||
return null;
|
||||
}
|
||||
throw new Error("Unexpected service member");
|
||||
},
|
||||
|
|
@ -210,42 +148,21 @@ describe("Session protocol", () => {
|
|||
},
|
||||
},
|
||||
};
|
||||
const server = createServer(host);
|
||||
const client = connect(server);
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
const attached = client.next((message) => message.type === "attachment" && message.attachment !== null);
|
||||
const attachResponse = client.next((message) => message.type === "response" && message.id === "service-attach");
|
||||
await client.sendMessage({
|
||||
type: "request",
|
||||
id: "service-attach",
|
||||
target: { serverId: "00000000-0000-4000-8000-000000000001" },
|
||||
call: { serviceId: "pi.session-management", member: "attach", args: ["session-1"] },
|
||||
});
|
||||
await expect(client.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
|
||||
await expect(attached).resolves.toMatchObject({
|
||||
type: "attachment",
|
||||
attachment: { sessionId: "session-1", attachmentId: expect.any(String) },
|
||||
});
|
||||
await expect(attachResponse).resolves.toEqual({
|
||||
type: "response",
|
||||
id: "service-attach",
|
||||
ok: true,
|
||||
});
|
||||
expect(backing.latestHarness("session-1").attachedClients).toBe(1);
|
||||
|
||||
const detached = client.next((message) => message.type === "attachment" && message.attachment === null);
|
||||
const detachResponse = client.next((message) => message.type === "response" && message.id === "service-detach");
|
||||
await client.sendMessage({
|
||||
type: "request",
|
||||
id: "service-detach",
|
||||
target: { serverId: "00000000-0000-4000-8000-000000000001" },
|
||||
call: { serviceId: "pi.session-management", member: "detach", args: [] },
|
||||
});
|
||||
await expect(
|
||||
client.requestService({ serverId }, { serviceId: "pi.session-management", member: "detach", args: [] }),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
await expect(detached).resolves.toMatchObject({ type: "attachment", attachment: null });
|
||||
await expect(detachResponse).resolves.toEqual({
|
||||
type: "response",
|
||||
id: "service-detach",
|
||||
ok: true,
|
||||
});
|
||||
expect(backing.latestHarness("session-1").attachedClients).toBe(0);
|
||||
await client.close();
|
||||
await expect.poll(() => releaseCount).toBe(1);
|
||||
|
|
@ -256,15 +173,9 @@ describe("Session protocol", () => {
|
|||
await host.seed("session-1");
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
await client.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "attach",
|
||||
args: ["session-1"],
|
||||
});
|
||||
await client.attach(serverId, "session-1");
|
||||
|
||||
const watched = await client.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "watch",
|
||||
args: ["session-1"],
|
||||
});
|
||||
const watched = await client.request(serverId, { method: "watch", args: ["session-1"] });
|
||||
expect(watched).toMatchObject({
|
||||
ok: true,
|
||||
result: { snapshot: { lane: "main", transcript: [], operation: null } },
|
||||
|
|
@ -279,12 +190,10 @@ describe("Session protocol", () => {
|
|||
}
|
||||
const watchId = watched.result.watchId;
|
||||
if (typeof watchId !== "string") throw new Error("Invalid watch ID");
|
||||
await expect(
|
||||
client.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "watch",
|
||||
args: ["session-1"],
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: false, error: { code: "watch_in_use" } });
|
||||
await expect(client.request(serverId, { method: "watch", args: ["session-1"] })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "watch_in_use" },
|
||||
});
|
||||
await host.latestHarness("session-1").emitEvent({
|
||||
type: "run_start",
|
||||
lane: "main",
|
||||
|
|
@ -294,10 +203,7 @@ describe("Session protocol", () => {
|
|||
expect(client.messages.some((message) => message.type === "event")).toBe(false);
|
||||
|
||||
const messageIndex = client.messages.length;
|
||||
const starting = client.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "startWatch",
|
||||
args: ["session-1", watchId],
|
||||
});
|
||||
const starting = client.request(serverId, { method: "startWatch", args: ["session-1", watchId] });
|
||||
await expect(client.nextFrom(messageIndex, (message) => message.type === "event")).resolves.toMatchObject({
|
||||
type: "event",
|
||||
watchId,
|
||||
|
|
@ -305,10 +211,7 @@ describe("Session protocol", () => {
|
|||
});
|
||||
await expect(starting).resolves.toMatchObject({ ok: true, result: { watchId } });
|
||||
await expect(
|
||||
client.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "resnapshotWatch",
|
||||
args: ["session-1", watchId],
|
||||
}),
|
||||
client.request(serverId, { method: "resnapshotWatch", args: ["session-1", watchId] }),
|
||||
).resolves.toMatchObject({ ok: true, result: { watchId, snapshot: { lane: "main" } } });
|
||||
|
||||
await host.latestHarness("session-1").emitEvent({
|
||||
|
|
@ -321,16 +224,9 @@ describe("Session protocol", () => {
|
|||
event: { type: "message_start" },
|
||||
});
|
||||
|
||||
await client.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "stopWatch",
|
||||
args: ["session-1", watchId],
|
||||
});
|
||||
await client.request(serverId, { method: "stopWatch", args: ["session-1", watchId] });
|
||||
const stoppedAt = client.messages.length;
|
||||
await host.latestHarness("session-1").emitEvent({
|
||||
type: "run_resume",
|
||||
lane: "main",
|
||||
runId: "run-1",
|
||||
});
|
||||
await host.latestHarness("session-1").emitEvent({ type: "run_resume", lane: "main", runId: "run-1" });
|
||||
expect(client.messages.slice(stoppedAt).some((message) => message.type === "event")).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -342,55 +238,28 @@ describe("Session protocol", () => {
|
|||
const second = connect(server);
|
||||
await Promise.all([first.hello(), second.hello()]);
|
||||
|
||||
await expect(
|
||||
first.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "attach",
|
||||
args: ["session-1"],
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } });
|
||||
await expect(
|
||||
first.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "attach",
|
||||
args: ["session-1"],
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } });
|
||||
await expect(first.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
|
||||
await expect(first.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
|
||||
expect(host.latestHarness("session-1").attachedClients).toBe(1);
|
||||
await expect(
|
||||
second.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "attach",
|
||||
args: ["session-1"],
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } });
|
||||
await expect(second.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
|
||||
expect(host.harnesses.get("session-1")).toHaveLength(1);
|
||||
expect(host.latestHarness("session-1").attachedClients).toBe(2);
|
||||
|
||||
await first.close();
|
||||
await expect.poll(() => host.latestHarness("session-1").attachedClients).toBe(1);
|
||||
await expect(
|
||||
second.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "attach",
|
||||
args: ["session-1"],
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } });
|
||||
await expect(second.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
test("clears connection ownership when attachment release fails", async () => {
|
||||
const host = new TestServerHost();
|
||||
await host.seed("session-1");
|
||||
const errors: Error[] = [];
|
||||
const server = new Server(host, {
|
||||
listeners: [],
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
const server = new Server(host, { listeners: [], serverId, onError: (error) => errors.push(error) });
|
||||
servers.add(server);
|
||||
const first = connect(server);
|
||||
const second = connect(server);
|
||||
await Promise.all([first.hello(), second.hello()]);
|
||||
await first.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "attach",
|
||||
args: ["session-1"],
|
||||
});
|
||||
await first.attach(serverId, "session-1");
|
||||
const harness = host.latestHarness("session-1");
|
||||
const releaseError = new Error("release failed");
|
||||
harness.failAttachmentRelease = releaseError;
|
||||
|
|
@ -399,12 +268,7 @@ describe("Session protocol", () => {
|
|||
await expect.poll(() => harness.attachmentReleaseCount).toBe(1);
|
||||
await expect.poll(() => errors).toContain(releaseError);
|
||||
harness.failAttachmentRelease = undefined;
|
||||
await expect(
|
||||
second.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "attach",
|
||||
args: ["session-1"],
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } });
|
||||
await expect(second.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
test("requires the requesting client to hold the targeted Session attachment", async () => {
|
||||
|
|
@ -415,166 +279,94 @@ describe("Session protocol", () => {
|
|||
const unattached = connect(server);
|
||||
await Promise.all([attached.hello(), unattached.hello()]);
|
||||
|
||||
await expect(
|
||||
unattached.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "prompt",
|
||||
args: ["session-1", ["Hello"]],
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: false, error: { code: "session_not_attached" } });
|
||||
await attached.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "attach",
|
||||
args: ["session-1"],
|
||||
await expect(unattached.requestSessionService(serverId, "session-1", sessionCall("run"))).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "session_not_attached" },
|
||||
});
|
||||
await attached.attach(serverId, "session-1");
|
||||
await expect(attached.requestSessionService(serverId, "session-2", sessionCall("run"))).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "session_not_attached" },
|
||||
});
|
||||
await expect(
|
||||
attached.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "prompt",
|
||||
args: ["session-2", ["Hello"]],
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: false, error: { code: "session_not_attached" } });
|
||||
|
||||
await expect(
|
||||
attached.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "prompt",
|
||||
args: ["session-1", ["Hello"]],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
type: "response",
|
||||
id: "request-3",
|
||||
ok: true,
|
||||
result: {
|
||||
ok: true,
|
||||
value: {
|
||||
operationId: "run-1",
|
||||
kind: "run",
|
||||
status: "completed",
|
||||
fromTipId: null,
|
||||
tipId: "leaf-1",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(host.latestHarness("session-1").promptCalls).toEqual([["Hello"]]);
|
||||
attached.requestSessionService(serverId, "session-1", sessionCall("run", ["Hello"])),
|
||||
).resolves.toMatchObject({ ok: true, result: { ok: true } });
|
||||
expect(host.latestHarness("session-1").serviceCalls).toEqual([sessionCall("run", ["Hello"])]);
|
||||
});
|
||||
|
||||
test("rejects a stale attachment route after switching Sessions", async () => {
|
||||
const host = new TestServerHost();
|
||||
await Promise.all([host.seed("session-1"), host.seed("session-2")]);
|
||||
const client = connect(createServer(host));
|
||||
const serverId = "00000000-0000-4000-8000-000000000001";
|
||||
await client.hello();
|
||||
const first = await client.request(serverId, { method: "attach", args: ["session-1"] });
|
||||
if (
|
||||
!first.ok ||
|
||||
typeof first.result !== "object" ||
|
||||
first.result === null ||
|
||||
!("attachmentId" in first.result) ||
|
||||
typeof first.result.attachmentId !== "string"
|
||||
) {
|
||||
throw new Error("Missing first attachment ID");
|
||||
}
|
||||
await client.request(serverId, { method: "attach", args: ["session-2"] });
|
||||
await client.attach(serverId, "session-1");
|
||||
const firstAttachmentId = latestAttachmentId(client, "session-1");
|
||||
await client.attach(serverId, "session-2");
|
||||
|
||||
const response = client.next((message) => message.type === "response" && message.id === "stale-request");
|
||||
await client.sendMessage({
|
||||
type: "request",
|
||||
id: "stale-request",
|
||||
target: { serverId, sessionId: "session-1", attachmentId: first.result.attachmentId },
|
||||
call: { serviceId: "pi.chat", member: "prompt", args: [["stale"]] },
|
||||
});
|
||||
await expect(response).resolves.toMatchObject({
|
||||
type: "response",
|
||||
ok: false,
|
||||
error: { code: "session_not_attached" },
|
||||
});
|
||||
expect(host.latestHarness("session-1").promptCalls).toEqual([]);
|
||||
await expect(
|
||||
client.requestService(
|
||||
{ serverId, sessionId: "session-1", attachmentId: firstAttachmentId },
|
||||
sessionCall("run", ["stale"]),
|
||||
),
|
||||
).resolves.toMatchObject({ ok: false, error: { code: "session_not_attached" } });
|
||||
expect(host.latestHarness("session-1").serviceCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test("preserves structural Harness failures and bounds adapter defects", async () => {
|
||||
test("preserves opaque service results and bounds adapter defects", async () => {
|
||||
const host = new TestServerHost();
|
||||
await host.seed("session-1");
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
await client.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "attach",
|
||||
args: ["session-1"],
|
||||
});
|
||||
await client.attach(serverId, "session-1");
|
||||
const harness = host.latestHarness("session-1");
|
||||
harness.nextPromptResult = { ok: false, error: { _tag: "Closed", message: "Harness closed" } };
|
||||
await expect(
|
||||
client.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "prompt",
|
||||
args: ["session-1", ["Hello"]],
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
harness.nextServiceResult = { accepted: false, reason: "closed" };
|
||||
await expect(client.requestSessionService(serverId, "session-1", sessionCall("run"))).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: { ok: false, error: { _tag: "Closed", message: "Harness closed" } },
|
||||
result: { accepted: false, reason: "closed" },
|
||||
});
|
||||
|
||||
harness.nextPromptError = new Error("private adapter detail");
|
||||
await expect(
|
||||
client.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "prompt",
|
||||
args: ["session-1", ["Hello"]],
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
harness.nextServiceError = new Error("private adapter detail");
|
||||
await expect(client.requestSessionService(serverId, "session-1", sessionCall("run"))).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "internal_error", message: "Internal server error" },
|
||||
});
|
||||
});
|
||||
|
||||
test("admits concurrent prompts so the Harness owns lane-busy semantics", async () => {
|
||||
test("admits concurrent service calls to the attached Session", async () => {
|
||||
const host = new TestServerHost();
|
||||
await host.seed("session-1");
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
await client.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "attach",
|
||||
args: ["session-1"],
|
||||
});
|
||||
await client.attach(serverId, "session-1");
|
||||
const harness = host.latestHarness("session-1");
|
||||
const gate = harness.gateNextPrompt();
|
||||
const first = client.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "prompt",
|
||||
args: ["session-1", ["first"]],
|
||||
});
|
||||
const gate = harness.gateNextServiceCall();
|
||||
const first = client.requestSessionService(serverId, "session-1", sessionCall("run", ["first"]));
|
||||
await gate.entered.promise;
|
||||
const second = client.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "prompt",
|
||||
args: ["session-1", ["second"]],
|
||||
});
|
||||
const second = client.requestSessionService(serverId, "session-1", sessionCall("run", ["second"]));
|
||||
|
||||
await expect(second).resolves.toMatchObject({ ok: true, result: { ok: true } });
|
||||
expect(harness.promptCalls).toEqual([["first"], ["second"]]);
|
||||
await expect(second).resolves.toMatchObject({ ok: true });
|
||||
expect(harness.serviceCalls).toEqual([sessionCall("run", ["first"]), sessionCall("run", ["second"])]);
|
||||
gate.release.resolve(undefined);
|
||||
await expect(first).resolves.toMatchObject({ ok: true, result: { ok: true } });
|
||||
await expect(first).resolves.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
test("keeps attachment demand until an accepted prompt settles after disconnect", async () => {
|
||||
test("keeps attachment demand until an accepted service call settles after disconnect", async () => {
|
||||
const host = new TestServerHost();
|
||||
await host.seed("session-1");
|
||||
const server = createServer(host);
|
||||
const client = connect(server);
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
await client.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "attach",
|
||||
args: ["session-1"],
|
||||
});
|
||||
await client.attach(serverId, "session-1");
|
||||
const harness = host.latestHarness("session-1");
|
||||
const gate = harness.gateNextPrompt();
|
||||
const prompting = client.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "prompt",
|
||||
args: ["session-1", ["Hello"]],
|
||||
});
|
||||
const disconnectedPrompt = expect(prompting).rejects.toThrow(/closed/i);
|
||||
const gate = harness.gateNextServiceCall();
|
||||
const calling = client.requestSessionService(serverId, "session-1", sessionCall("run"));
|
||||
const disconnectedCall = expect(calling).rejects.toThrow(/closed/i);
|
||||
await gate.entered.promise;
|
||||
|
||||
await client.close();
|
||||
expect(harness.attachedClients).toBe(1);
|
||||
gate.release.resolve(undefined);
|
||||
await disconnectedPrompt;
|
||||
await disconnectedCall;
|
||||
await expect.poll(() => harness.attachedClients).toBe(0);
|
||||
expect(harness.promptCalls).toEqual([["Hello"]]);
|
||||
});
|
||||
|
||||
test("rejects requests addressed to another server before repository access", async () => {
|
||||
|
|
@ -583,9 +375,7 @@ describe("Session protocol", () => {
|
|||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
|
||||
await expect(
|
||||
client.request("00000000-0000-4000-8000-000000000002", { method: "attach", args: ["session-1"] }),
|
||||
).resolves.toMatchObject({
|
||||
await expect(client.attach("00000000-0000-4000-8000-000000000002", "session-1")).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "wrong_server" },
|
||||
});
|
||||
|
|
@ -597,9 +387,7 @@ describe("Session protocol", () => {
|
|||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
|
||||
await expect(
|
||||
client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["missing"] }),
|
||||
).resolves.toMatchObject({
|
||||
await expect(client.attach(serverId, "missing")).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "session_not_found" },
|
||||
});
|
||||
|
|
@ -609,39 +397,32 @@ describe("Session protocol", () => {
|
|||
test("rejects an ambiguous session ID without creating a Harness", async () => {
|
||||
type BackendMetadata = SessionMetadata & { path: string };
|
||||
const host: ServerHost<BackendMetadata> = {
|
||||
serverServices: createTestServerServices(),
|
||||
sessions: {
|
||||
list: async () => [
|
||||
{ id: "duplicate", createdAt: 1, storageVersion: 1, cwd: "/one", path: "/one/session.jsonl" },
|
||||
{ id: "duplicate", createdAt: 2, storageVersion: 1, cwd: "/two", path: "/two/session.jsonl" },
|
||||
],
|
||||
create: async () => {
|
||||
throw new Error("must not create a Session in this test");
|
||||
},
|
||||
},
|
||||
openSession: async () => {
|
||||
throw new Error("must not create a Harness for an ambiguous session");
|
||||
},
|
||||
};
|
||||
const server = new Server(host, {
|
||||
listeners: [],
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
});
|
||||
servers.add(server);
|
||||
const client = connect(server);
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
|
||||
await expect(
|
||||
client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["duplicate"] }),
|
||||
).resolves.toMatchObject({ ok: false, error: { code: "session_ambiguous" } });
|
||||
await expect(client.attach(serverId, "duplicate")).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "session_ambiguous" },
|
||||
});
|
||||
});
|
||||
|
||||
test("invalidates a terminated Harness handle and allows a later attach", async () => {
|
||||
const host = new TestServerHost();
|
||||
await host.seed("session-1");
|
||||
const server = createServer(host);
|
||||
const client = connect(server);
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
await client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"] });
|
||||
await client.attach(serverId, "session-1");
|
||||
const firstHarness = host.latestHarness("session-1");
|
||||
|
||||
await firstHarness.terminate(new Error("worker crashed"));
|
||||
|
|
@ -649,9 +430,7 @@ describe("Session protocol", () => {
|
|||
await expect.poll(() => firstHarness.attachedClients).toBe(0);
|
||||
expect(firstHarness.attachmentReleaseCount).toBe(1);
|
||||
|
||||
await expect(
|
||||
client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"] }),
|
||||
).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } });
|
||||
await expect(client.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
|
||||
expect(host.harnesses.get("session-1")).toHaveLength(2);
|
||||
});
|
||||
|
||||
|
|
@ -661,7 +440,7 @@ describe("Session protocol", () => {
|
|||
const server = createServer(host);
|
||||
const client = connect(server);
|
||||
await client.hello();
|
||||
await client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"] });
|
||||
await client.attach(serverId, "session-1");
|
||||
const harness = host.latestHarness("session-1");
|
||||
|
||||
await client.close();
|
||||
|
|
@ -680,25 +459,14 @@ describe("routed Session acquisition failures", () => {
|
|||
const terminated = new Deferred<Error | undefined>();
|
||||
let releaseCount = 0;
|
||||
const host: ServerHost = {
|
||||
sessions: { list: async () => [metadata], create: async () => metadata },
|
||||
serverServices: createTestServerServices(),
|
||||
sessions: { list: async () => [metadata] },
|
||||
openSession: async () => ({
|
||||
terminated: terminated.promise,
|
||||
attachClient: async () => {
|
||||
acquiring.resolve(undefined);
|
||||
await continueAcquiring.promise;
|
||||
return {
|
||||
prompt: async () => ({
|
||||
ok: true,
|
||||
value: {
|
||||
operationId: "run-1",
|
||||
kind: "run" as const,
|
||||
status: "completed" as const,
|
||||
fromTipId: null,
|
||||
tipId: "leaf-1",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
},
|
||||
}),
|
||||
release: () => {
|
||||
releaseCount += 1;
|
||||
},
|
||||
|
|
@ -707,17 +475,9 @@ describe("routed Session acquisition failures", () => {
|
|||
close: async () => {},
|
||||
}),
|
||||
};
|
||||
const server = new Server(host, {
|
||||
listeners: [],
|
||||
serverId: "00000000-0000-4000-8000-000000000001",
|
||||
});
|
||||
servers.add(server);
|
||||
const client = connect(server);
|
||||
const client = connect(createServer(host));
|
||||
await client.hello();
|
||||
const attach = client.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "attach",
|
||||
args: ["session-1"],
|
||||
});
|
||||
const attach = client.attach(serverId, "session-1");
|
||||
await acquiring.promise;
|
||||
|
||||
terminated.resolve(new Error("worker crashed"));
|
||||
|
|
@ -736,15 +496,9 @@ describe("routed Session acquisition failures", () => {
|
|||
await Promise.all([first.hello(), second.hello()]);
|
||||
const gate = host.gateNextOpenSession();
|
||||
|
||||
const firstAttach = first.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "attach",
|
||||
args: ["session-1"],
|
||||
});
|
||||
const firstAttach = first.attach(serverId, "session-1");
|
||||
await gate.entered.promise;
|
||||
const secondAttach = second.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "attach",
|
||||
args: ["session-1"],
|
||||
});
|
||||
const secondAttach = second.attach(serverId, "session-1");
|
||||
gate.release.resolve(undefined);
|
||||
|
||||
await expect(Promise.all([firstAttach, secondAttach])).resolves.toMatchObject([
|
||||
|
|
@ -753,9 +507,7 @@ describe("routed Session acquisition failures", () => {
|
|||
]);
|
||||
expect(host.openSessionCount).toBe(1);
|
||||
|
||||
await expect(
|
||||
first.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"] }),
|
||||
).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } });
|
||||
await expect(first.attach(serverId, "session-1")).resolves.toMatchObject({ ok: true });
|
||||
expect(host.openSessionCount).toBe(2);
|
||||
expect(host.harnesses.get("session-1")).toHaveLength(1);
|
||||
});
|
||||
|
|
@ -767,10 +519,7 @@ describe("routed Session acquisition failures", () => {
|
|||
const client = connect(server);
|
||||
await client.hello();
|
||||
const gate = host.gateNextOpenSession();
|
||||
const attach = client.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "attach",
|
||||
args: ["session-1"],
|
||||
});
|
||||
const attach = client.attach(serverId, "session-1");
|
||||
await gate.entered.promise;
|
||||
const closing = server.close();
|
||||
gate.release.resolve(undefined);
|
||||
|
|
@ -789,10 +538,7 @@ describe("routed Session acquisition failures", () => {
|
|||
const client = connect(server);
|
||||
await client.hello();
|
||||
const gate = host.gateNextOpenSession();
|
||||
const attach = client.request("00000000-0000-4000-8000-000000000001", {
|
||||
method: "attach",
|
||||
args: ["session-1"],
|
||||
});
|
||||
const attach = client.attach(serverId, "session-1");
|
||||
await gate.entered.promise;
|
||||
|
||||
const closing = server.close();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue