mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
fix(agents): retain creation identity and dispatch evidence across hosts
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import type { DaemonClient } from "../daemon-client.js";
|
||||
|
||||
/** Presents atomic current run evidence and subsequent checkpoints through one snapshot callback. */
|
||||
export function observeAgentRun(client: DaemonClient, agentId: string, submissionId: string) {
|
||||
const source = client.observeAgentRun(agentId, submissionId);
|
||||
return {
|
||||
get subscriptionId() {
|
||||
return source.subscriptionId;
|
||||
},
|
||||
ready: source.ready,
|
||||
release: () => source.release(),
|
||||
subscribe(observer: Parameters<typeof source.subscribe>[0]) {
|
||||
return source.subscribe({
|
||||
snapshot: observer.snapshot,
|
||||
update(message) {
|
||||
if (message.type === "agent.runs.changed") observer.snapshot(message.payload);
|
||||
else observer.update(message);
|
||||
},
|
||||
error: observer.error,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AgentMessageSource } from "@getpaseo/protocol/agent-message";
|
||||
import type { AgentSendResult } from "@getpaseo/protocol/agent-dispatch";
|
||||
import {
|
||||
HOST_ROUTE_OPCODE,
|
||||
encodeHostRouteFrame,
|
||||
@@ -17,7 +19,7 @@ import {
|
||||
} from "./connection/index.js";
|
||||
import { CreationClient } from "./creation/index.js";
|
||||
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
|
||||
import type { z } from "zod";
|
||||
import { z } from "zod";
|
||||
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
|
||||
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||
import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification";
|
||||
@@ -434,6 +436,9 @@ export interface DaemonClientTrace {
|
||||
}
|
||||
|
||||
export interface SendMessageOptions {
|
||||
source?: AgentMessageSource;
|
||||
/** Suppress delivery when the recipient is archived, instead of unarchiving. */
|
||||
unarchive?: boolean;
|
||||
sourceAgentId?: string;
|
||||
messageId?: string;
|
||||
/** What happens when the agent is mid-turn. The daemon interrupts the turn when omitted. */
|
||||
@@ -1685,23 +1690,42 @@ export class DaemonClient {
|
||||
code: "unsupported_capability",
|
||||
});
|
||||
}
|
||||
observeAgentCreation(idempotencyKey: string) {
|
||||
observeCreation(input: { kind: "agent" | "workspace"; idempotencyKey: string }) {
|
||||
this.requireFleetSubagents();
|
||||
return this.observe("creation.subscribe.response", {
|
||||
type: "creation.subscribe.request",
|
||||
kind: "agent",
|
||||
idempotencyKey,
|
||||
...input,
|
||||
});
|
||||
}
|
||||
async cancelAgentCreation(input: { idempotencyKey: string; agentId: string }) {
|
||||
async closeAgentSession(agentId: string): Promise<void> {
|
||||
this.requireFleetSubagents();
|
||||
await this.sendCorrelatedSessionRequest({
|
||||
message: { type: "agent.close.request", agentId },
|
||||
responseType: "agent.close.response",
|
||||
});
|
||||
}
|
||||
async cancelCreation(creation: import("@getpaseo/protocol/messages").CreationCancelInput) {
|
||||
this.requireFleetSubagents();
|
||||
return (
|
||||
await this.sendCorrelatedSessionRequest({
|
||||
message: { type: "agent.create.cancel.request", ...input },
|
||||
responseType: "agent.create.cancel.response",
|
||||
message: { type: "creation.cancel.request", creation },
|
||||
responseType: "creation.cancel.response",
|
||||
})
|
||||
).cancellation;
|
||||
}
|
||||
observeAgentRun(agentId: string, submissionId: string) {
|
||||
this.requireFleetSubagents();
|
||||
return this.owned.observe(async (accept) => {
|
||||
const result = await this.sendCorrelatedSessionRequest({
|
||||
message: { type: "agent.runs.get.request", agentId, submissionId, observe: true },
|
||||
responseType: "agent.runs.get.response",
|
||||
});
|
||||
if (!result.subscriptionId) throw new Error("Host omitted run subscription identity");
|
||||
const snapshot = { run: result.run, subscriptionId: result.subscriptionId };
|
||||
accept(snapshot);
|
||||
return snapshot;
|
||||
});
|
||||
}
|
||||
async getAgentRun(agentId: string, submissionId: string) {
|
||||
this.requireFleetSubagents();
|
||||
return (
|
||||
@@ -3783,17 +3807,22 @@ export class DaemonClient {
|
||||
// Agent Interaction
|
||||
// ============================================================================
|
||||
|
||||
async sendAgentMessage(
|
||||
agentId: string,
|
||||
text: string,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<void> {
|
||||
private assertMessageSendSupported(options: SendMessageOptions | undefined): void {
|
||||
if (
|
||||
options?.sourceAgentId &&
|
||||
this.lastServerInfoMessage?.features?.agentMessageProvenance !== true
|
||||
) {
|
||||
throw new Error("Update the Paseo host to send messages with agent provenance.");
|
||||
}
|
||||
if (options?.unarchive !== undefined || options?.source) this.requireFleetSubagents();
|
||||
}
|
||||
|
||||
async sendAgentMessageWithReceipt(
|
||||
agentId: string,
|
||||
text: string,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<AgentSendResult> {
|
||||
this.assertMessageSendSupported(options);
|
||||
const requestId = this.createRequestId();
|
||||
const messageId = options?.messageId ?? crypto.randomUUID();
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
@@ -3802,10 +3831,12 @@ export class DaemonClient {
|
||||
agentId,
|
||||
text,
|
||||
sourceAgentId: options?.sourceAgentId,
|
||||
...(messageId ? { messageId } : {}),
|
||||
...(options?.activeTurnBehavior ? { activeTurnBehavior: options.activeTurnBehavior } : {}),
|
||||
...(options?.images ? { images: options.images } : {}),
|
||||
...(options?.attachments ? { attachments: options.attachments } : {}),
|
||||
source: options?.source,
|
||||
unarchive: options?.unarchive,
|
||||
messageId,
|
||||
activeTurnBehavior: options?.activeTurnBehavior,
|
||||
images: options?.images,
|
||||
attachments: options?.attachments,
|
||||
});
|
||||
const payload = await this.sendRequest({
|
||||
requestId,
|
||||
@@ -3822,8 +3853,26 @@ export class DaemonClient {
|
||||
},
|
||||
});
|
||||
if (!payload.accepted) {
|
||||
throw new Error(payload.error ?? "sendAgentMessage rejected");
|
||||
throw Object.assign(new Error(payload.error ?? "sendAgentMessage rejected"), {
|
||||
code: payload.code,
|
||||
outcome: payload.outcome,
|
||||
});
|
||||
}
|
||||
// COMPAT(message-dispatch-evidence): added in v0.12, remove after 2027-04-20.
|
||||
// Older hosts and completed receipts have no evidence of their dispatch disposition.
|
||||
return {
|
||||
submissionId: messageId,
|
||||
disposition: payload.dispatch?.disposition ?? "unknown",
|
||||
tracking: payload.dispatch?.tracking ?? "unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
async sendAgentMessage(
|
||||
agentId: string,
|
||||
text: string,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<void> {
|
||||
await this.sendAgentMessageWithReceipt(agentId, text, options);
|
||||
}
|
||||
|
||||
async sendMessage(agentId: string, text: string, options?: SendMessageOptions): Promise<void> {
|
||||
@@ -3864,6 +3913,14 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
async cancelAgent(agentId: string): Promise<void> {
|
||||
await this.requestAgentCancellation(agentId);
|
||||
}
|
||||
async cancelAgentWithStatus(agentId: string): Promise<{ cancelled: boolean }> {
|
||||
this.requireFleetSubagents();
|
||||
const result = await this.requestAgentCancellation(agentId);
|
||||
return z.object({ cancelled: z.boolean() }).parse(result);
|
||||
}
|
||||
private async requestAgentCancellation(agentId: string) {
|
||||
const requestId = this.createRequestId();
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "cancel_agent_request",
|
||||
@@ -3884,9 +3941,8 @@ export class DaemonClient {
|
||||
return msg.payload;
|
||||
},
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
if (payload.error) throw new Error(payload.error);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async setAgentMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null> {
|
||||
|
||||
@@ -266,11 +266,13 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own
|
||||
expect(Object.keys(paseo).sort()).toEqual([
|
||||
"agents",
|
||||
"config",
|
||||
"creations",
|
||||
"dispose",
|
||||
"hosts",
|
||||
"observeEvents",
|
||||
"projects",
|
||||
"providers",
|
||||
"schedules",
|
||||
"terminals",
|
||||
"workspaces",
|
||||
]);
|
||||
|
||||
+101
-17
@@ -1,3 +1,8 @@
|
||||
import { observeAgentRun } from "./agents/runs.js";
|
||||
import type { AgentSendResult } from "@getpaseo/protocol/agent-dispatch";
|
||||
export type { AgentSendResult } from "@getpaseo/protocol/agent-dispatch";
|
||||
import { createScheduleActions, type PaseoScheduleActions } from "./schedules/index.js";
|
||||
export type { PaseoScheduleActions } from "./schedules/index.js";
|
||||
import { observeAgent } from "./agents/observation.js";
|
||||
import { safeRandomId } from "./daemon-client-transport-utils.js";
|
||||
import { createHostActions, type PaseoHostActions } from "./hosts/index.js";
|
||||
@@ -188,6 +193,14 @@ export interface PaseoWorkspaceHandle {
|
||||
readonly agents: {
|
||||
create(options: PaseoWorkspaceAgentCreateOptions): Promise<PaseoAgentHandle>;
|
||||
};
|
||||
readonly scripts: {
|
||||
list(requestId?: string): ReturnType<DaemonClient["listWorkspaceScripts"]>;
|
||||
start(
|
||||
scriptName: string,
|
||||
requestId?: string,
|
||||
): ReturnType<DaemonClient["startWorkspaceScriptWithStatus"]>;
|
||||
stop(scriptName: string, requestId?: string): ReturnType<DaemonClient["stopWorkspaceScript"]>;
|
||||
};
|
||||
readonly terminals: PaseoWorkspaceTerminalActions;
|
||||
current(): PaseoWorkspace | null;
|
||||
refresh(options?: { requestId?: string }): Promise<PaseoWorkspace | null>;
|
||||
@@ -238,7 +251,7 @@ export type PaseoAgentProvider = PaseoAgentSessionConfig["provider"];
|
||||
export type PaseoProviderFeatureValues = Record<string, unknown>;
|
||||
|
||||
export interface PaseoAgentConfig {
|
||||
/** Provider and model in `provider/model` format. */
|
||||
/** Provider ID, or a provider/model pair when inspecting a selected model. */
|
||||
provider: string;
|
||||
modeId?: PaseoAgentSessionConfig["modeId"];
|
||||
thinkingOptionId?: PaseoAgentSessionConfig["thinkingOptionId"];
|
||||
@@ -273,7 +286,9 @@ export interface PaseoAgentCreateOptions {
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type PaseoWorkspaceAgentCreateOptions = Omit<PaseoAgentCreateOptions, "cwd">;
|
||||
export type PaseoWorkspaceAgentCreateOptions = Omit<PaseoAgentCreateOptions, "cwd"> & {
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
export interface PaseoAgentRefetchResult {
|
||||
agent: PaseoAgent;
|
||||
@@ -351,6 +366,7 @@ export interface PaseoAgentTimelineHandle {
|
||||
export type PaseoSubagentCreateOptions = Omit<PaseoAgentCreateOptions, "parent" | "cwd"> & {
|
||||
serverId?: string;
|
||||
workspaceId?: string;
|
||||
workspace?: import("@getpaseo/protocol/messages").SubagentCreateInput["workspace"];
|
||||
cwd?: string;
|
||||
};
|
||||
export interface PaseoSubagentActions {
|
||||
@@ -365,6 +381,7 @@ export type { AgentRunRecord, AgentRunCheckpoint } from "@getpaseo/protocol/agen
|
||||
export interface PaseoAgentHandle {
|
||||
observe(): OwnedSubscription<PaseoAgent>;
|
||||
readonly runs: {
|
||||
observe(submissionId: string): ReturnType<DaemonClient["observeAgentRun"]>;
|
||||
get(
|
||||
submissionId: string,
|
||||
): Promise<import("@getpaseo/protocol/agent-runs").AgentRunRecord | null>;
|
||||
@@ -395,7 +412,7 @@ export interface PaseoAgentHandle {
|
||||
readonly timeline: PaseoAgentTimelineHandle;
|
||||
current(): PaseoAgent | null;
|
||||
refresh(requestId?: string): Promise<PaseoAgentRefetchResult | null>;
|
||||
send(text: string, options?: PaseoAgentSendOptions): Promise<void>;
|
||||
send(text: string, options?: PaseoAgentSendOptions): Promise<AgentSendResult>;
|
||||
respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise<void>;
|
||||
/** Sends a prompt and resolves when that turn finishes or needs attention. */
|
||||
run(text: string, options?: PaseoAgentRunOptions): Promise<PaseoAgentRunResult>;
|
||||
@@ -409,6 +426,13 @@ export interface PaseoAgentHandle {
|
||||
* rejecting.
|
||||
*/
|
||||
commands(options?: PaseoAgentCommandsOptions): Promise<PaseoAgentCommandsResult>;
|
||||
cancel(): ReturnType<DaemonClient["cancelAgentWithStatus"]>;
|
||||
close(): Promise<void>;
|
||||
update(input: Parameters<DaemonClient["updateAgent"]>[1]): Promise<void>;
|
||||
setMode(modeId: string): ReturnType<DaemonClient["setAgentMode"]>;
|
||||
configure(
|
||||
config: Parameters<DaemonClient["applyAgentConfig"]>[1],
|
||||
): ReturnType<DaemonClient["applyAgentConfig"]>;
|
||||
archive(): Promise<{ archivedAt: string }>;
|
||||
detach(): Promise<void>;
|
||||
subscribe(handler: (update: PaseoAgentUpdate) => void): () => void;
|
||||
@@ -517,16 +541,19 @@ export interface PaseoConfigActions {
|
||||
}
|
||||
|
||||
export interface PaseoCreationActions {
|
||||
observeAgent(idempotencyKey: string): ReturnType<DaemonClient["observeAgentCreation"]>;
|
||||
/** Cancel only pre-admission creation; admitted work retains its existing receipt. */
|
||||
cancelAgent(input: {
|
||||
observe(input: {
|
||||
kind: "agent" | "workspace";
|
||||
idempotencyKey: string;
|
||||
agentId: string;
|
||||
}): Promise<import("@getpaseo/protocol/messages").AgentCreationCancellation>;
|
||||
}): ReturnType<DaemonClient["observeCreation"]>;
|
||||
/** Fences pre-admission creation; admitted work retains its original receipt. */
|
||||
cancel(
|
||||
input: import("@getpaseo/protocol/messages").CreationCancelInput,
|
||||
): Promise<import("@getpaseo/protocol/messages").CreationCancellation>;
|
||||
}
|
||||
|
||||
export interface PaseoApi {
|
||||
readonly creations: PaseoCreationActions;
|
||||
readonly schedules: PaseoScheduleActions;
|
||||
readonly hosts: PaseoHostActions;
|
||||
dispose(): Promise<void>;
|
||||
observeEvents: DaemonClient["observeEvents"];
|
||||
@@ -641,6 +668,7 @@ export function createPaseoApi(
|
||||
(agentId, handler) => own(() => daemonClient.subscribeAgentTimeline(agentId, handler)),
|
||||
lifetime.signal,
|
||||
(agentId) => own(() => observeAgent(daemonClient, agentId)),
|
||||
(agentId, submissionId) => own(() => observeAgentRun(daemonClient, agentId, submissionId)),
|
||||
(parentId) => ({
|
||||
detach: (child) => {
|
||||
if (lifetime.signal.aborted) throw new Error("Paseo API is disposed");
|
||||
@@ -655,7 +683,7 @@ export function createPaseoApi(
|
||||
return daemonClient.listSubagents(parentId);
|
||||
},
|
||||
observe: () => own(() => daemonClient.observeSubagents(parentId)),
|
||||
create: async ({ serverId, idempotencyKey, ...input }) => {
|
||||
create: async ({ serverId, idempotencyKey, workspace, ...input }) => {
|
||||
if (lifetime.signal.aborted) throw new Error("Paseo API is disposed");
|
||||
const normalized = toDaemonAgentCreateOptions(
|
||||
{ ...input, cwd: input.cwd ?? "" },
|
||||
@@ -664,6 +692,7 @@ export function createPaseoApi(
|
||||
const child = await daemonClient.createSubagent(parentId, {
|
||||
serverId: serverId ?? requireServerIdentity(daemonClient.getServerId()),
|
||||
idempotencyKey: idempotencyKey ?? safeRandomId(),
|
||||
workspace,
|
||||
input: { ...normalized, labels: input.labels ?? {}, config: normalized.config! },
|
||||
});
|
||||
subagentRuntime ??= hosts.runtime();
|
||||
@@ -722,6 +751,7 @@ export function createPaseoApi(
|
||||
createAgent,
|
||||
terminals,
|
||||
listenWorkspaces,
|
||||
lifetime.signal,
|
||||
);
|
||||
|
||||
let disposal: Promise<void> | null = null;
|
||||
@@ -814,11 +844,12 @@ export function createPaseoApi(
|
||||
|
||||
return {
|
||||
dispose,
|
||||
schedules: createScheduleActions(daemonClient, lifetime.signal),
|
||||
creations: {
|
||||
observeAgent: (key) => own(() => daemonClient.observeAgentCreation(key)),
|
||||
cancelAgent: (input) => {
|
||||
observe: (input) => own(() => daemonClient.observeCreation(input)),
|
||||
cancel: (input) => {
|
||||
if (lifetime.signal.aborted) throw new Error("Paseo API is disposed");
|
||||
return daemonClient.cancelAgentCreation(input);
|
||||
return daemonClient.cancelCreation(input);
|
||||
},
|
||||
},
|
||||
hosts,
|
||||
@@ -866,8 +897,11 @@ export function createPaseoApi(
|
||||
listModels: (provider, options) => daemonClient.listProviderModels(provider, options),
|
||||
listModes: (provider, options) => daemonClient.listProviderModes(provider, options),
|
||||
listFeatures: ({ provider: providerModel, ...draftConfig }, options) => {
|
||||
const { provider, model } = parseProviderModel(providerModel);
|
||||
return daemonClient.listProviderFeatures({ ...draftConfig, provider, model }, options);
|
||||
const { provider, model } = parseDraftProviderSelection(providerModel);
|
||||
return daemonClient.listProviderFeatures(
|
||||
{ ...draftConfig, provider, ...(model ? { model } : {}) },
|
||||
options,
|
||||
);
|
||||
},
|
||||
listAvailable: (options) => daemonClient.listAvailableProviders(options),
|
||||
snapshot: (options) => daemonClient.getProvidersSnapshot(options),
|
||||
@@ -906,9 +940,13 @@ function createWorkspaceHandleFactory(
|
||||
createAgent: CreateAgent,
|
||||
terminals: PaseoTerminalActions,
|
||||
listen: (handler: PaseoWorkspaceUpdateHandler) => () => void,
|
||||
signal: AbortSignal,
|
||||
): WorkspaceHandleFactory {
|
||||
return (workspace) => {
|
||||
const id = typeof workspace === "string" ? workspace : workspace.id;
|
||||
const assertActive = () => {
|
||||
if (signal.aborted) throw new Error("Paseo API is disposed");
|
||||
};
|
||||
let current = typeof workspace === "string" ? null : workspace;
|
||||
|
||||
const refresh = async (options?: { requestId?: string }) => {
|
||||
@@ -955,11 +993,25 @@ function createWorkspaceHandleFactory(
|
||||
throw new Error(`Workspace ${id} has no available directory`);
|
||||
}
|
||||
return createAgent(
|
||||
{ ...options, cwd: snapshot.workspaceDirectory },
|
||||
{ workspaceId: id, cwd: snapshot.workspaceDirectory },
|
||||
{ ...options, cwd: options.cwd ?? snapshot.workspaceDirectory },
|
||||
{ workspaceId: id, cwd: options.cwd ?? snapshot.workspaceDirectory },
|
||||
);
|
||||
},
|
||||
},
|
||||
scripts: {
|
||||
list: async (requestId) => {
|
||||
assertActive();
|
||||
return daemonClient.listWorkspaceScripts(id, requestId);
|
||||
},
|
||||
start: async (scriptName, requestId) => {
|
||||
assertActive();
|
||||
return daemonClient.startWorkspaceScriptWithStatus(id, scriptName, requestId);
|
||||
},
|
||||
stop: async (scriptName, requestId) => {
|
||||
assertActive();
|
||||
return daemonClient.stopWorkspaceScript(id, scriptName, requestId);
|
||||
},
|
||||
},
|
||||
terminals: {
|
||||
create: (options) => terminals.create({ ...options, workspaceId: id }),
|
||||
list: (options) => terminals.list({ ...options, workspaceId: id }),
|
||||
@@ -994,6 +1046,7 @@ function createAgentHandleFactory(
|
||||
subscribeTimeline: DaemonClient["subscribeAgentTimeline"],
|
||||
signal: AbortSignal,
|
||||
observeSnapshot: (agentId: string) => OwnedSubscription<PaseoAgent>,
|
||||
observeRun: DaemonClient["observeAgentRun"],
|
||||
subagents: (parentId: string) => PaseoSubagentActions,
|
||||
): AgentHandleFactory {
|
||||
const assertActive = () => {
|
||||
@@ -1023,6 +1076,10 @@ function createAgentHandleFactory(
|
||||
return observation;
|
||||
},
|
||||
runs: {
|
||||
observe: (submissionId) => {
|
||||
assertActive();
|
||||
return observeRun(id, submissionId);
|
||||
},
|
||||
get: (submissionId) => {
|
||||
assertActive();
|
||||
return daemonClient.getAgentRun(id, submissionId);
|
||||
@@ -1120,7 +1177,7 @@ function createAgentHandleFactory(
|
||||
},
|
||||
send: async (text, options) => {
|
||||
assertActive();
|
||||
await daemonClient.sendAgentMessage(id, text, options);
|
||||
return daemonClient.sendAgentMessageWithReceipt(id, text, options);
|
||||
},
|
||||
respondToPermission: async ({ requestId, response }) => {
|
||||
assertActive();
|
||||
@@ -1155,6 +1212,26 @@ function createAgentHandleFactory(
|
||||
assertActive();
|
||||
return daemonClient.listCommands({ agentId: id, ...options });
|
||||
},
|
||||
cancel: async () => {
|
||||
assertActive();
|
||||
return daemonClient.cancelAgentWithStatus(id);
|
||||
},
|
||||
close: async () => {
|
||||
assertActive();
|
||||
await daemonClient.closeAgentSession(id);
|
||||
},
|
||||
update: async (input) => {
|
||||
assertActive();
|
||||
await daemonClient.updateAgent(id, input);
|
||||
},
|
||||
setMode: (modeId) => {
|
||||
assertActive();
|
||||
return daemonClient.setAgentMode(id, modeId);
|
||||
},
|
||||
configure: (config) => {
|
||||
assertActive();
|
||||
return daemonClient.applyAgentConfig(id, config);
|
||||
},
|
||||
archive: async () => {
|
||||
assertActive();
|
||||
const result = await daemonClient.archiveAgent(id);
|
||||
@@ -1217,6 +1294,13 @@ function requireServerIdentity(serverId: string | null): string {
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function parseDraftProviderSelection(selection: string): { provider: string; model?: string } {
|
||||
if (selection.includes("/")) return parseProviderModel(selection);
|
||||
const provider = selection.trim();
|
||||
if (!provider) throw new Error("Provider ID is required");
|
||||
return { provider };
|
||||
}
|
||||
|
||||
function parseProviderModel(selection: string): { provider: string; model: string } {
|
||||
const separator = selection.indexOf("/");
|
||||
if (separator <= 0 || separator === selection.length - 1) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { DaemonClient } from "../daemon-client.js";
|
||||
|
||||
export interface PaseoScheduleActions {
|
||||
create: DaemonClient["scheduleCreate"];
|
||||
list: DaemonClient["scheduleList"];
|
||||
inspect: DaemonClient["scheduleInspect"];
|
||||
update: DaemonClient["scheduleUpdate"];
|
||||
pause: DaemonClient["schedulePause"];
|
||||
resume: DaemonClient["scheduleResume"];
|
||||
delete: DaemonClient["scheduleDelete"];
|
||||
logs: DaemonClient["scheduleLogs"];
|
||||
runOnce: DaemonClient["scheduleRunOnce"];
|
||||
}
|
||||
|
||||
export function createScheduleActions(
|
||||
client: DaemonClient,
|
||||
signal: AbortSignal,
|
||||
): PaseoScheduleActions {
|
||||
const action =
|
||||
<A extends unknown[], R>(method: (...args: A) => Promise<R>) =>
|
||||
(...args: A): Promise<R> => {
|
||||
if (signal.aborted) return Promise.reject(new Error("Paseo API is disposed"));
|
||||
return method(...args);
|
||||
};
|
||||
return {
|
||||
create: action(client.scheduleCreate.bind(client)),
|
||||
list: action(client.scheduleList.bind(client)),
|
||||
inspect: action(client.scheduleInspect.bind(client)),
|
||||
update: action(client.scheduleUpdate.bind(client)),
|
||||
pause: action(client.schedulePause.bind(client)),
|
||||
resume: action(client.scheduleResume.bind(client)),
|
||||
delete: action(client.scheduleDelete.bind(client)),
|
||||
logs: action(client.scheduleLogs.bind(client)),
|
||||
runOnce: action(client.scheduleRunOnce.bind(client)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Dispatch acknowledgment describes admission, never provider completion. */
|
||||
export const AgentDispatchSchema = z.object({
|
||||
disposition: z.enum(["turn_started", "steered", "out_of_band", "suppressed"]),
|
||||
tracking: z.enum(["turn", "unavailable"]),
|
||||
});
|
||||
export type AgentDispatch = z.infer<typeof AgentDispatchSchema>;
|
||||
export interface AgentSendResult {
|
||||
submissionId: string;
|
||||
// Older receipts/hosts did not record dispatch evidence.
|
||||
disposition: AgentDispatch["disposition"] | "unknown";
|
||||
tracking: AgentDispatch["tracking"];
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentAddressSchema } from "./addresses.js";
|
||||
import { z } from "zod";
|
||||
|
||||
/** Semantic provenance, independent of provider envelopes and timeline presentation. */
|
||||
@@ -5,9 +6,18 @@ export const AgentMessageSchema = z.object({
|
||||
event: z.enum(["message", "finished", "errored", "permission-required", "closed"]),
|
||||
sender: z.object({
|
||||
id: z.string().min(1),
|
||||
address: AgentAddressSchema.optional(),
|
||||
title: z.string().min(1).optional(),
|
||||
}),
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
export type AgentMessage = z.infer<typeof AgentMessageSchema>;
|
||||
|
||||
/** Declared message provenance; authenticated principal/source ownership is separate. */
|
||||
export const AgentMessageSourceSchema = z.object({
|
||||
agent: AgentAddressSchema,
|
||||
title: z.string().min(1).optional(),
|
||||
event: z.enum(["message", "finished", "errored", "permission-required", "closed"]),
|
||||
});
|
||||
export type AgentMessageSource = z.infer<typeof AgentMessageSourceSchema>;
|
||||
|
||||
@@ -41,8 +41,18 @@ export const AgentRunGetRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
agentId: z.string(),
|
||||
submissionId: z.string(),
|
||||
observe: z.boolean().optional(),
|
||||
});
|
||||
export const AgentRunGetResponseSchema = z.object({
|
||||
type: z.literal("agent.runs.get.response"),
|
||||
payload: z.object({ requestId: z.string(), run: AgentRunRecordSchema.nullable() }),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
subscriptionId: z.string().optional(),
|
||||
run: AgentRunRecordSchema.nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const AgentRunChangedSchema = z.object({
|
||||
type: z.literal("agent.runs.changed"),
|
||||
payload: z.object({ subscriptionId: z.string(), run: AgentRunRecordSchema.nullable() }),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { AgentRunGetRequestSchema, AgentRunGetResponseSchema } from "./agent-runs.js";
|
||||
import { AgentDispatchSchema } from "./agent-dispatch.js";
|
||||
import {
|
||||
AgentRunGetRequestSchema,
|
||||
AgentRunGetResponseSchema,
|
||||
AgentRunChangedSchema,
|
||||
} from "./agent-runs.js";
|
||||
import { AgentAddressSchema, WorkspaceAddressSchema } from "./addresses.js";
|
||||
import { AgentMessageSchema } from "./agent-message.js";
|
||||
import { AgentMessageSchema, AgentMessageSourceSchema } from "./agent-message.js";
|
||||
import {
|
||||
HostsListRequestSchema,
|
||||
HostsAddRequestSchema,
|
||||
@@ -1392,6 +1397,8 @@ export const SendAgentMessageRequestSchema = z.object({
|
||||
text: z.string(),
|
||||
/** Opaque sender identity for agent-originated prompts. */
|
||||
sourceAgentId: z.string().min(1).optional(),
|
||||
source: AgentMessageSourceSchema.optional(),
|
||||
unarchive: z.boolean().optional(),
|
||||
messageId: z.string().optional(), // Client-provided ID for deduplication
|
||||
activeTurnBehavior: ActiveTurnBehaviorSchema.optional(),
|
||||
images: z.array(ImageAttachmentSchema).optional(),
|
||||
@@ -3255,6 +3262,12 @@ export type SubagentDirectory = z.infer<typeof SubagentDirectorySchema>;
|
||||
export const SubagentCreateInputSchema = z.object({
|
||||
serverId: z.string().min(1),
|
||||
idempotencyKey: z.string().min(1).max(512),
|
||||
workspace: WorkspaceCreateRequestSchema.omit({
|
||||
type: true,
|
||||
requestId: true,
|
||||
agent: true,
|
||||
subscribe: true,
|
||||
}).optional(),
|
||||
input: CreateAgentRequestMessageSchema.omit({
|
||||
type: true,
|
||||
requestId: true,
|
||||
@@ -3312,15 +3325,39 @@ export const SubagentsChangedSchema = z.object({
|
||||
payload: z.object({ subscriptionId: z.string(), directory: SubagentDirectorySchema }),
|
||||
});
|
||||
|
||||
export const AgentCreationCancelRequestSchema = z.object({
|
||||
type: z.literal("agent.create.cancel.request"),
|
||||
export const AgentCloseRequestSchema = z.object({
|
||||
type: z.literal("agent.close.request"),
|
||||
requestId: z.string(),
|
||||
idempotencyKey: z.string().min(1),
|
||||
agentId: z.string().uuid(),
|
||||
agentId: z.string(),
|
||||
});
|
||||
export const AgentCloseResponseSchema = z.object({
|
||||
type: z.literal("agent.close.response"),
|
||||
payload: z.object({ requestId: z.string() }),
|
||||
});
|
||||
|
||||
export const CreationCancelInputSchema = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("agent"),
|
||||
idempotencyKey: z.string().min(1),
|
||||
agentId: z.string().uuid(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("workspace"),
|
||||
idempotencyKey: z.string().min(1),
|
||||
workspaceId: z.string().regex(/^wks_[a-f0-9]{16}$/),
|
||||
agentId: z.string().uuid(),
|
||||
}),
|
||||
]);
|
||||
export type CreationCancelInput = z.infer<typeof CreationCancelInputSchema>;
|
||||
export const CreationCancelRequestSchema = z.object({
|
||||
type: z.literal("creation.cancel.request"),
|
||||
requestId: z.string(),
|
||||
creation: CreationCancelInputSchema,
|
||||
});
|
||||
|
||||
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
AgentCreationCancelRequestSchema,
|
||||
AgentCloseRequestSchema,
|
||||
CreationCancelRequestSchema,
|
||||
AgentRunGetRequestSchema,
|
||||
SubagentsDetachRequestSchema,
|
||||
SubagentsForgetRequestSchema,
|
||||
@@ -5008,6 +5045,7 @@ export const CancelAgentResponseMessageSchema = z.object({
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
agentId: z.string(),
|
||||
cancelled: z.boolean().optional(),
|
||||
agent: AgentSnapshotPayloadSchema.nullable(),
|
||||
error: z.string().nullable().optional(),
|
||||
}),
|
||||
@@ -5044,18 +5082,26 @@ export const CreationSnapshotSchema = z.object({
|
||||
failedStage: z.enum(["workspace", "agent", "prompt"]).optional(),
|
||||
outcomeUnknown: z.boolean().optional(),
|
||||
});
|
||||
export const AgentCreationCancellationSchema = z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("canceled"), reservedAgent: AgentAddressSchema }),
|
||||
const CreationReservationSchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("agent"), reservedAgent: AgentAddressSchema }),
|
||||
z.object({
|
||||
kind: z.literal("workspace"),
|
||||
reservedAgent: AgentAddressSchema,
|
||||
reservedWorkspace: WorkspaceAddressSchema,
|
||||
}),
|
||||
]);
|
||||
export const CreationCancellationSchema = z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("canceled"), reservation: CreationReservationSchema }),
|
||||
z.object({
|
||||
status: z.literal("admitted"),
|
||||
reservedAgent: AgentAddressSchema,
|
||||
reservation: CreationReservationSchema,
|
||||
creation: CreationSnapshotSchema,
|
||||
}),
|
||||
]);
|
||||
export type AgentCreationCancellation = z.infer<typeof AgentCreationCancellationSchema>;
|
||||
export const AgentCreationCancelResponseSchema = z.object({
|
||||
type: z.literal("agent.create.cancel.response"),
|
||||
payload: z.object({ requestId: z.string(), cancellation: AgentCreationCancellationSchema }),
|
||||
export type CreationCancellation = z.infer<typeof CreationCancellationSchema>;
|
||||
export const CreationCancelResponseSchema = z.object({
|
||||
type: z.literal("creation.cancel.response"),
|
||||
payload: z.object({ requestId: z.string(), cancellation: CreationCancellationSchema }),
|
||||
});
|
||||
export type CreationSnapshot = z.infer<typeof CreationSnapshotSchema>;
|
||||
export type AgentCreateRequest = z.infer<typeof AgentCreateRequestSchema>;
|
||||
@@ -5138,6 +5184,9 @@ export const SendAgentMessageResponseMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
agentId: z.string(),
|
||||
accepted: z.boolean(),
|
||||
dispatch: AgentDispatchSchema.optional(),
|
||||
code: z.string().optional(),
|
||||
outcome: z.literal("unknown").optional(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
@@ -7023,8 +7072,10 @@ export const AgentSkillsImportLegacySelectionResponseSchema = z.object({
|
||||
});
|
||||
|
||||
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
AgentCreationCancelResponseSchema,
|
||||
AgentCloseResponseSchema,
|
||||
CreationCancelResponseSchema,
|
||||
AgentRunGetResponseSchema,
|
||||
AgentRunChangedSchema,
|
||||
SubagentsDetachResponseSchema,
|
||||
SubagentsForgetResponseSchema,
|
||||
SubagentsCreateResponseSchema,
|
||||
|
||||
@@ -157,6 +157,7 @@ export const PASEO_TOOL_CATALOG = {
|
||||
context: targetAgent(),
|
||||
detail: { inputOrder: ["agentId", "limit"] },
|
||||
},
|
||||
list_hosts: { label: "List hosts" },
|
||||
list_agents: {
|
||||
label: "List agents",
|
||||
detail: { inputOrder: ["cwd", "statuses", "sinceHours", "limit", "includeArchived"] },
|
||||
|
||||
@@ -533,6 +533,7 @@ class TestAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
class ResumeTrackingTestAgentClient extends TestAgentClient {
|
||||
retryStartCount = 0;
|
||||
private readonly retryStarted = deferred<void>();
|
||||
|
||||
override async resumeSession(
|
||||
@@ -540,7 +541,10 @@ class ResumeTrackingTestAgentClient extends TestAgentClient {
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
this.resumeOverrides.push(config);
|
||||
const signalRetryStarted = () => this.retryStarted.resolve();
|
||||
const signalRetryStarted = () => {
|
||||
this.retryStartCount++;
|
||||
this.retryStarted.resolve();
|
||||
};
|
||||
return new (class extends TestAgentSession {
|
||||
override async startTurn(): Promise<{ turnId: string }> {
|
||||
signalRetryStarted();
|
||||
@@ -665,6 +669,7 @@ async function startAndSteerThroughManager(
|
||||
activeTurnBehavior: behavior,
|
||||
runOptions: { clientMessageId: "replacement-client" },
|
||||
});
|
||||
await manager.waitForAgentRunStart(agent.id);
|
||||
return { manager, agentId: agent.id, workdir };
|
||||
}
|
||||
|
||||
@@ -962,6 +967,14 @@ test("orders an accepted steer before output emitted while acknowledgement is pe
|
||||
release.resolve();
|
||||
await expect(steer).resolves.toEqual({ status: "accepted" });
|
||||
await consume;
|
||||
expect(await manager.getAgentRun(agent.id, "hello-client")).toMatchObject({
|
||||
submissionId: "hello-client",
|
||||
turnId: "active-turn-1",
|
||||
status: "completed",
|
||||
checkpoints: expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "completed", result: "terminal output" }),
|
||||
]),
|
||||
});
|
||||
const rows = manager.fetchTimeline(agent.id, { limit: 0 }).rows.filter((row) => {
|
||||
return (
|
||||
(row.item.type === "user_message" && row.item.text === "hello") ||
|
||||
@@ -4033,6 +4046,7 @@ test("a prompt after provider replacement reopens the stale session", async () =
|
||||
const result = await manager.waitForAgentEvent(created.id);
|
||||
expect(result.status).toBe("idle");
|
||||
expect(replacement.resumeOverrides).toHaveLength(1);
|
||||
expect(replacement.retryStartCount).toBe(1);
|
||||
const timeline = manager.getTimeline(created.id);
|
||||
expect(timeline.filter((item) => item.type === "user_message")).toMatchObject([
|
||||
{ text: "continue after reload", clientMessageId: "stale-session-retry" },
|
||||
@@ -8630,7 +8644,7 @@ test("fires onAgentArchived for archived parent and cascaded children", async ()
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
manager.setAgentArchivedCallback((agentId) => {
|
||||
manager.onAgentArchived((agentId) => {
|
||||
archivedIds.push(agentId);
|
||||
});
|
||||
|
||||
@@ -8664,7 +8678,7 @@ test("fires onAgentArchived for stored-only snapshot archives", async () => {
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
manager.setAgentArchivedCallback((agentId) => {
|
||||
manager.onAgentArchived((agentId) => {
|
||||
archivedIds.push(agentId);
|
||||
});
|
||||
|
||||
@@ -12173,3 +12187,123 @@ test("run journal reload binds subsequent evidence to the original submission an
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("all accepted submissions bound to one actual turn recover its evidence after restart", async () => {
|
||||
const { AgentRuns } = await import("./runs/index.js");
|
||||
const { mkdtemp, rm } = await import("node:fs/promises");
|
||||
const directory = await mkdtemp(join(tmpdir(), "paseo-run-aliases-"));
|
||||
try {
|
||||
const first = new AgentRuns({ serverId: "srv_original", directory });
|
||||
for (const submission of ["initial", "steer-one", "steer-two"]) {
|
||||
await first.prepare("agent", submission);
|
||||
await first.start("agent", submission, "actual-turn");
|
||||
}
|
||||
const restarted = new AgentRuns({ serverId: "srv_original", directory });
|
||||
await restarted.capture(
|
||||
"agent",
|
||||
"actual-turn",
|
||||
{ type: "turn_completed", provider: "codex" },
|
||||
"shared answer",
|
||||
);
|
||||
for (const submission of ["initial", "steer-one", "steer-two"]) {
|
||||
expect(await restarted.get("agent", submission)).toMatchObject({
|
||||
submissionId: submission,
|
||||
turnId: "actual-turn",
|
||||
status: "completed",
|
||||
checkpoints: expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "completed", result: "shared answer" }),
|
||||
]),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("run admission permits only owner-rejected identical input, never pending or restarted uncertainty", async () => {
|
||||
const { AgentRuns } = await import("./runs/index.js");
|
||||
const { mkdtemp, rm } = await import("node:fs/promises");
|
||||
const directory = await mkdtemp(join(tmpdir(), "run-admission-rejection-"));
|
||||
try {
|
||||
const journal = new AgentRuns({ serverId: "srv_test", directory });
|
||||
await journal.prepare("agent", "submission", { prompt: "original" });
|
||||
await expect(
|
||||
journal.prepare("agent", "submission", { prompt: "original" }),
|
||||
).rejects.toMatchObject({ code: "submission_exists" });
|
||||
await expect(
|
||||
journal.prepare("agent", "submission", { prompt: "different" }),
|
||||
).rejects.toMatchObject({ code: "submission_exists" });
|
||||
await journal.rejectUnadmitted("agent", "submission");
|
||||
await expect(
|
||||
journal.prepare("agent", "submission", { prompt: "different" }),
|
||||
).rejects.toMatchObject({ code: "submission_exists" });
|
||||
await journal.prepare("agent", "submission", { prompt: "original" });
|
||||
const restarted = new AgentRuns({ serverId: "srv_test", directory });
|
||||
expect(await restarted.get("agent", "submission")).toMatchObject({
|
||||
status: "interrupted",
|
||||
turnId: null,
|
||||
});
|
||||
await expect(
|
||||
restarted.prepare("agent", "submission", { prompt: "original" }),
|
||||
).rejects.toMatchObject({ code: "submission_exists" });
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a gated first admission excludes competing same-submission work through the manager", async () => {
|
||||
const entered = deferred<void>();
|
||||
const release = deferred<void>();
|
||||
let admissions = 0;
|
||||
class GatedAdmission extends SteeringTestSession {
|
||||
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||
admissions++;
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
return super.startTurn(prompt);
|
||||
}
|
||||
}
|
||||
const session = new GatedAdmission({ provider: "codex", cwd: process.cwd() });
|
||||
const manager = new AgentManager({
|
||||
serverId: "srv_test",
|
||||
logger,
|
||||
clients: {
|
||||
codex: new (class extends TestAgentClient {
|
||||
override async createSession() {
|
||||
return session;
|
||||
}
|
||||
})(),
|
||||
},
|
||||
});
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: process.cwd() }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const consume = (async () => {
|
||||
for await (const _event of manager.streamAgent(agent.id, "original", {
|
||||
clientMessageId: "one-submission",
|
||||
})) {
|
||||
}
|
||||
})();
|
||||
try {
|
||||
await entered.promise;
|
||||
for (const prompt of ["original", "different input"])
|
||||
expect(() =>
|
||||
manager.streamAgent(agent.id, prompt, { clientMessageId: "one-submission" }),
|
||||
).toThrow("already has an active run");
|
||||
expect(admissions).toBe(1);
|
||||
release.resolve();
|
||||
await manager.waitForAgentRunStart(agent.id);
|
||||
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-1" });
|
||||
await consume;
|
||||
expect(admissions).toBe(1);
|
||||
expect(session.startPrompts).toEqual(["original"]);
|
||||
expect(await manager.getAgentRun(agent.id, "one-submission")).toMatchObject({
|
||||
status: "completed",
|
||||
turnId: "active-turn-1",
|
||||
});
|
||||
} finally {
|
||||
release.resolve();
|
||||
await manager.closeAgent(agent.id);
|
||||
await consume.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -767,14 +767,14 @@ export class AgentManager {
|
||||
) => ProviderPaseoToolsPolicy | undefined;
|
||||
private appendSystemPrompt: string;
|
||||
private onAgentAttention?: AgentAttentionCallback;
|
||||
private onAgentArchived?: AgentArchivedCallback;
|
||||
private readonly agentArchivedListeners = new Set<AgentArchivedCallback>();
|
||||
private onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
|
||||
private logger: Logger;
|
||||
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
|
||||
private readonly beforeSteerUnavailableFallback?: AgentManagerOptions["beforeSteerUnavailableFallback"];
|
||||
private acceptingAgentRegistrations = true;
|
||||
|
||||
private readonly serverId: string;
|
||||
readonly serverId: string;
|
||||
|
||||
constructor(options: AgentManagerOptions) {
|
||||
this.serverId = options.serverId;
|
||||
@@ -866,12 +866,19 @@ export class AgentManager {
|
||||
this.onAgentAttention = callback;
|
||||
}
|
||||
|
||||
observeAgentRun(...args: Parameters<AgentRuns["observe"]>) {
|
||||
return this.runJournal.observe(...args);
|
||||
}
|
||||
|
||||
getAgentRun(agentId: string, submissionId: string) {
|
||||
return this.runJournal.get(agentId, submissionId);
|
||||
}
|
||||
|
||||
setAgentArchivedCallback(callback: AgentArchivedCallback): void {
|
||||
this.onAgentArchived = callback;
|
||||
onAgentArchived(callback: AgentArchivedCallback): () => void {
|
||||
this.agentArchivedListeners.add(callback);
|
||||
return () => {
|
||||
this.agentArchivedListeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
setMcpBaseUrl(url: string | null): void {
|
||||
@@ -1988,14 +1995,14 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
private async fireAgentArchived(agentId: string): Promise<void> {
|
||||
const callback = this.onAgentArchived;
|
||||
if (!callback) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await callback(agentId);
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, agentId }, "onAgentArchived callback failed");
|
||||
const results = await Promise.allSettled(
|
||||
[...this.agentArchivedListeners].map(async (listener) => {
|
||||
await listener(agentId);
|
||||
}),
|
||||
);
|
||||
for (const result of results) {
|
||||
if (result.status === "rejected")
|
||||
this.logger.warn({ err: result.reason, agentId }, "Agent archive listener failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2572,7 +2579,7 @@ export class AgentManager {
|
||||
const { agent, agentId, pendingRun, prompt, options } = params;
|
||||
try {
|
||||
const submissionId = options?.clientMessageId ?? pendingRun.token;
|
||||
await this.runJournal.prepare(agentId, submissionId);
|
||||
await this.runJournal.prepare(agentId, submissionId, { prompt, options });
|
||||
const result = await agent.session.startTurn(prompt, options);
|
||||
try {
|
||||
await this.runJournal.start(agentId, submissionId, result.turnId);
|
||||
@@ -2593,6 +2600,14 @@ export class AgentManager {
|
||||
throw error;
|
||||
}
|
||||
if (isStaleProviderSessionError(error)) {
|
||||
await this.runJournal
|
||||
.rejectUnadmitted(agentId, options?.clientMessageId ?? pendingRun.token)
|
||||
.catch((journalError) => {
|
||||
this.logger.error(
|
||||
{ err: journalError, agentId },
|
||||
"Failed to persist definitive admission rejection",
|
||||
);
|
||||
});
|
||||
pendingRun.start = { status: "failed", error: error.message };
|
||||
agent.pendingReplacement = false;
|
||||
if (!agent.activeForegroundTurnId) agent.lifecycle = "idle";
|
||||
@@ -3009,6 +3024,12 @@ export class AgentManager {
|
||||
if (!clientMessageId) {
|
||||
return;
|
||||
}
|
||||
await this.runJournal.acceptSteer(agent.id, clientMessageId, expectedTurnId).catch((error) => {
|
||||
this.logger.error(
|
||||
{ err: error, agentId: agent.id, submissionId: clientMessageId },
|
||||
"Failed to persist accepted steer identity",
|
||||
);
|
||||
});
|
||||
this.recordSubmittedPrompt(agent, prompt, clientMessageId, "accepted", {
|
||||
messageId: clientMessageId,
|
||||
turnId: expectedTurnId,
|
||||
@@ -3559,6 +3580,9 @@ export class AgentManager {
|
||||
hasStarted = true;
|
||||
return;
|
||||
}
|
||||
// A run selected for dispatch is still admitting even if the previous
|
||||
// session's idle snapshot is replayed before provider admission settles.
|
||||
if (this.runs.getPendingRun(agentId)?.start.status === "pending") return;
|
||||
if (!waitForActive || hasStarted) {
|
||||
if (terminalStatusOverride) {
|
||||
currentStatus = terminalStatusOverride;
|
||||
@@ -4378,6 +4402,15 @@ export class AgentManager {
|
||||
options?.fromHistory === true,
|
||||
terminalDisposition,
|
||||
);
|
||||
if (!options?.fromHistory) {
|
||||
if (terminalDisposition !== "stale")
|
||||
await this.runJournal.capture(agent.id, eventTurnId, event, runResult).catch((error) => {
|
||||
this.logger.error(
|
||||
{ err: error, agentId: agent.id, turnId: eventTurnId },
|
||||
"Failed to persist provider checkpoint",
|
||||
);
|
||||
});
|
||||
}
|
||||
const dispatchPromise = this.dispatchStreamEventByType({
|
||||
agent,
|
||||
event,
|
||||
@@ -4392,13 +4425,6 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
if (!options?.fromHistory) {
|
||||
if (terminalDisposition !== "stale")
|
||||
await this.runJournal.capture(agent.id, eventTurnId, event, runResult).catch((error) => {
|
||||
this.logger.error(
|
||||
{ err: error, agentId: agent.id, turnId: eventTurnId },
|
||||
"Failed to persist provider checkpoint",
|
||||
);
|
||||
});
|
||||
if (isTurnTerminalEvent(event)) {
|
||||
this.runs.settleTerminalRun(agent.id, eventTurnId);
|
||||
if (isForegroundEvent) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentMessageSource } from "@getpaseo/protocol/agent-message";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
@@ -12,17 +13,29 @@ const sourceSchema = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("agent-message"),
|
||||
agentId: agentIdSchema,
|
||||
serverId: z.string().min(1).optional(),
|
||||
title: z.string().min(1).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("agent-notification"),
|
||||
agentId: agentIdSchema,
|
||||
serverId: z.string().min(1).optional(),
|
||||
title: z.string().min(1).optional(),
|
||||
event: z.enum(["finished", "errored", "permission-required", "closed"]),
|
||||
}),
|
||||
]);
|
||||
export type AgentPromptSource = z.infer<typeof sourceSchema>;
|
||||
|
||||
export function fromMessageSource(source: AgentMessageSource): AgentPromptSource {
|
||||
const identity = {
|
||||
agentId: source.agent.agentId,
|
||||
serverId: source.agent.serverId,
|
||||
title: source.title,
|
||||
};
|
||||
if (source.event === "message") return { ...identity, kind: "agent-message" };
|
||||
return { ...identity, kind: "agent-notification", event: source.event };
|
||||
}
|
||||
|
||||
interface AgentMessage {
|
||||
id: string;
|
||||
source: AgentPromptSource | null;
|
||||
@@ -69,9 +82,10 @@ export function formatAgentMessage(message: AgentMessage): string {
|
||||
const attributes = source
|
||||
? `kind="${source.kind}" source-agent-id="${escapeXml(source.agentId)}"`
|
||||
: 'kind="user-message"';
|
||||
const server = source?.serverId ? ` source-server-id="${escapeXml(source.serverId)}"` : "";
|
||||
const title = source?.title ? ` source-agent-title="${escapeXml(source.title)}"` : "";
|
||||
const event = source?.kind === "agent-notification" ? ` event="${source.event}"` : "";
|
||||
return `<paseo-system version="1" ${attributes} message-id="${escapeXml(message.id)}"${title}${event}>\n${escapeXml(message.text)}\n</paseo-system>`;
|
||||
return `<paseo-system version="1" ${attributes} message-id="${escapeXml(message.id)}"${server}${title}${event}>\n${escapeXml(message.text)}\n</paseo-system>`;
|
||||
}
|
||||
|
||||
export function parseAgentMessage(text: string): AgentMessage | null {
|
||||
@@ -96,6 +110,7 @@ export function parseAgentMessage(text: string): AgentMessage | null {
|
||||
const source = sourceSchema.safeParse({
|
||||
kind: attributes.get("kind"),
|
||||
agentId: attributes.get("source-agent-id"),
|
||||
serverId: attributes.get("source-server-id"),
|
||||
event: attributes.get("event"),
|
||||
title: attributes.get("source-agent-title"),
|
||||
});
|
||||
@@ -162,7 +177,13 @@ export function projectAgentMessage(item: AgentTimelineItem): AgentTimelineItem
|
||||
callId: `paseo-agent-message:${message.id}`,
|
||||
agentMessage: {
|
||||
event: source.kind === "agent-message" ? "message" : source.event,
|
||||
sender: { id: source.agentId, ...(source.title ? { title: source.title } : {}) },
|
||||
sender: {
|
||||
id: source.agentId,
|
||||
...(source.serverId
|
||||
? { address: { serverId: source.serverId, agentId: source.agentId } }
|
||||
: {}),
|
||||
...(source.title ? { title: source.title } : {}),
|
||||
},
|
||||
text: message.text,
|
||||
},
|
||||
// COMPAT(agentMessageToolEnvelope): added in v0.11.1; remove after 2027-04-08
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface StartAgentRunOptions {
|
||||
clearPendingPermissions?: boolean;
|
||||
}
|
||||
|
||||
export type PromptDispatchDisposition = "out_of_band" | "steered" | "turn_started";
|
||||
export type PromptDispatchDisposition = "out_of_band" | "steered" | "turn_started" | "suppressed";
|
||||
|
||||
async function steerOrReplaceActiveRun(
|
||||
agentManager: AgentRunController,
|
||||
@@ -289,14 +289,18 @@ export async function waitForAgentRunStartWithTimeout(
|
||||
|
||||
async function resolvePromptSource(
|
||||
source: AgentPromptSource | undefined,
|
||||
manager: Pick<AgentManager, "getAgent">,
|
||||
manager: Pick<AgentManager, "getAgent" | "serverId">,
|
||||
storage: AgentStorage,
|
||||
): Promise<AgentPromptSource | undefined> {
|
||||
if (!source) return undefined;
|
||||
// COMPAT(local-message-source): added in v0.12, remove after 2027-04-20.
|
||||
// Legacy callers identify a source on this daemon. Qualify once before dispatch.
|
||||
const qualified = { ...source, serverId: source.serverId ?? manager.serverId };
|
||||
if (qualified.serverId !== manager.serverId) return qualified;
|
||||
const title = (
|
||||
manager.getAgent(source.agentId)?.config.title ?? (await storage.get(source.agentId))?.title
|
||||
)?.trim();
|
||||
return title ? { ...source, title } : source;
|
||||
return title ? { ...qualified, title } : qualified;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,8 +311,8 @@ async function resolvePromptSource(
|
||||
* chat mentions, notify-on-finish) MUST go through this so behavior can never
|
||||
* drift between them.
|
||||
*
|
||||
* When `unarchive` is false and the agent is archived, the call is a silent
|
||||
* no-op (returns the normal turn-start disposition) — the agent is not run.
|
||||
* When `unarchive` is false and the agent is archived, dispatch is suppressed.
|
||||
* No provider turn or run tracking record is created.
|
||||
*/
|
||||
export async function sendPromptToAgent(
|
||||
params: SendPromptToAgentParams,
|
||||
@@ -319,7 +323,7 @@ export async function sendPromptToAgent(
|
||||
let archivedAtToRestore: string | null = null;
|
||||
if (record?.archivedAt) {
|
||||
if (!unarchive) {
|
||||
return { disposition: "turn_started" };
|
||||
return { disposition: "suppressed" };
|
||||
}
|
||||
if (await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId)) {
|
||||
archivedAtToRestore = record.archivedAt;
|
||||
|
||||
@@ -158,7 +158,11 @@ const seededAgentProfile = {
|
||||
};
|
||||
|
||||
function createRecordingAgentClients(): Record<AgentProvider, AgentClient> {
|
||||
const baseClients = createTestAgentClients();
|
||||
const baseClients = createTestAgentClients({
|
||||
beforeSetFeature: async (featureId) => {
|
||||
if (featureId !== "test_feature") throw new Error(`Unsupported feature: ${featureId}`);
|
||||
},
|
||||
});
|
||||
launchConfigsByProvider = {};
|
||||
const wrappedClients: Record<AgentProvider, AgentClient> = {};
|
||||
|
||||
@@ -609,6 +613,92 @@ describe("Suite A: Core Fixes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("MCP settings failure preserves metadata and no-op update preserves the full snapshot", async () => {
|
||||
const agentId = await createTopLevelAgent();
|
||||
try {
|
||||
await callToolStructured(topLevelClient, "update_agent", {
|
||||
agentId,
|
||||
name: "Configured metadata",
|
||||
labels: { role: "worker" },
|
||||
settings: {
|
||||
modeId: "default",
|
||||
model: "claude-test-model",
|
||||
features: { test_feature: true },
|
||||
},
|
||||
});
|
||||
const before = await callToolStructured(topLevelClient, "get_agent_status", { agentId });
|
||||
expect(before.snapshot).toMatchObject({
|
||||
id: agentId,
|
||||
title: "Configured metadata",
|
||||
model: "claude-test-model",
|
||||
currentModeId: "default",
|
||||
labels: { role: "worker" },
|
||||
capabilities: expect.any(Object),
|
||||
availableModes: expect.any(Array),
|
||||
pendingPermissions: [],
|
||||
runtimeInfo: expect.any(Object),
|
||||
persistence: expect.any(Object),
|
||||
});
|
||||
expectAgentFeatureValue(
|
||||
z.record(z.string(), z.unknown()).parse(before.snapshot),
|
||||
"test_feature",
|
||||
true,
|
||||
);
|
||||
expect(await callToolStructured(topLevelClient, "update_agent", { agentId })).toMatchObject({
|
||||
success: true,
|
||||
});
|
||||
expect(
|
||||
(await callToolStructured(topLevelClient, "get_agent_status", { agentId })).snapshot,
|
||||
).toEqual(before.snapshot);
|
||||
await expectToolError(
|
||||
topLevelClient,
|
||||
"update_agent",
|
||||
{
|
||||
agentId,
|
||||
name: "Must not persist",
|
||||
labels: { role: "changed" },
|
||||
settings: { features: { unsupported_feature: true } },
|
||||
},
|
||||
/feature/i,
|
||||
);
|
||||
expect(
|
||||
(await callToolStructured(topLevelClient, "get_agent_status", { agentId })).snapshot,
|
||||
).toEqual(before.snapshot);
|
||||
} finally {
|
||||
await archiveAgentIfPresent(agentId);
|
||||
}
|
||||
});
|
||||
|
||||
test("MCP reads archived history and hides internal stored agents", async () => {
|
||||
const agentId = await createTopLevelAgent();
|
||||
await callToolStructured(topLevelClient, "archive_agent", { agentId });
|
||||
expect(await callToolStructured(topLevelClient, "get_agent_status", { agentId })).toMatchObject(
|
||||
{
|
||||
status: "closed",
|
||||
snapshot: { id: agentId, archivedAt: expect.any(String) },
|
||||
},
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
const internalId = crypto.randomUUID();
|
||||
await daemonHandle.daemon.agentStorage.upsert({
|
||||
id: internalId,
|
||||
internal: true,
|
||||
provider: "claude",
|
||||
cwd: parentAgentCwd,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
labels: {},
|
||||
lastStatus: "closed",
|
||||
title: "Internal history",
|
||||
});
|
||||
await expectToolError(
|
||||
topLevelClient,
|
||||
"get_agent_status",
|
||||
{ agentId: internalId },
|
||||
/not found/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("inspect_provider returns draft provider features over MCP", async () => {
|
||||
const payload = await callToolStructured(topLevelClient, "inspect_provider", {
|
||||
provider: "claude",
|
||||
@@ -1088,3 +1178,211 @@ describe("Suite E: Workspace Tools", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("MCP qualified control addresses the selected daemon with colliding agent IDs through one SDK workflow", async () => {
|
||||
const { createHostClient } = await import("@getpaseo/client/node");
|
||||
const { createPaseoApi } = await import("@getpaseo/client");
|
||||
const b = await createTestPaseoDaemon();
|
||||
const sourceClient = createHostClient(`127.0.0.1:${daemonHandle.port}`, {
|
||||
clientId: crypto.randomUUID(),
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
const targetClient = createHostClient(`127.0.0.1:${b.port}`, {
|
||||
clientId: crypto.randomUUID(),
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
await Promise.all([sourceClient.connect(), targetClient.connect()]);
|
||||
const source = createPaseoApi(sourceClient);
|
||||
const target = createPaseoApi(targetClient);
|
||||
try {
|
||||
const agentId = crypto.randomUUID();
|
||||
await source.agents.create({
|
||||
agentId,
|
||||
cwd: daemonHandle.staticDir,
|
||||
title: "Origin identity",
|
||||
config: { provider: "claude/claude-test-model" },
|
||||
});
|
||||
await target.agents.create({
|
||||
agentId,
|
||||
cwd: b.staticDir,
|
||||
title: "Destination identity",
|
||||
config: { provider: "claude/claude-test-model" },
|
||||
});
|
||||
const registration = await source.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||
await expect.poll(async () => (await source.hosts.list()).hosts[0]?.status).toBe("online");
|
||||
const serverId = b.daemon.getServerId();
|
||||
const status = await callToolStructured(topLevelClient, "get_agent_status", {
|
||||
serverId,
|
||||
agentId,
|
||||
});
|
||||
expect(status).toMatchObject({
|
||||
serverId,
|
||||
snapshot: { id: agentId, title: "Destination identity" },
|
||||
});
|
||||
await callToolStructured(topLevelClient, "update_agent", {
|
||||
serverId,
|
||||
agentId,
|
||||
name: "Changed through selected host",
|
||||
});
|
||||
await callToolStructured(topLevelClient, "set_agent_mode", {
|
||||
serverId,
|
||||
agentId,
|
||||
modeId: "default",
|
||||
});
|
||||
expect((await target.agents.ref(agentId).refresh())?.agent).toMatchObject({
|
||||
title: "Changed through selected host",
|
||||
currentModeId: "default",
|
||||
});
|
||||
expect((await source.agents.ref(agentId).refresh())?.agent.title).toBe("Origin identity");
|
||||
expect(
|
||||
await callToolStructured(topLevelClient, "cancel_agent", { serverId, agentId }),
|
||||
).toMatchObject({ serverId, success: false });
|
||||
expect(
|
||||
await callToolStructured(topLevelClient, "kill_agent", { serverId, agentId }),
|
||||
).toMatchObject({ serverId, success: true });
|
||||
expect(await target.agents.ref(agentId).refresh()).not.toBeNull();
|
||||
expect((await source.agents.ref(agentId).refresh())?.agent.title).toBe("Origin identity");
|
||||
expect(await callToolStructured(topLevelClient, "get_agent_status", { agentId })).toMatchObject(
|
||||
{ serverId: daemonHandle.daemon.getServerId(), snapshot: { title: "Origin identity" } },
|
||||
);
|
||||
await source.hosts.update(registration.registrationId, { enabled: false });
|
||||
const failure = await topLevelClient.callTool({
|
||||
name: "get_agent_status",
|
||||
args: { serverId, agentId },
|
||||
});
|
||||
expect(failure.isError).toBe(true);
|
||||
expect(getStructuredContent(failure)).toMatchObject({
|
||||
serverId,
|
||||
code: "host_disabled",
|
||||
error: expect.any(String),
|
||||
});
|
||||
} finally {
|
||||
await Promise.allSettled([source.dispose(), target.dispose()]);
|
||||
await Promise.allSettled([sourceClient.close(), targetClient.close()]);
|
||||
await b.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("MCP preserves selected destination and unknown submitted-action outcome at the client response", async () => {
|
||||
const { createHostClient } = await import("@getpaseo/client/node");
|
||||
const { createPaseoApi } = await import("@getpaseo/client");
|
||||
let release!: () => void;
|
||||
let reached!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
reached = resolve;
|
||||
});
|
||||
let closes = 0;
|
||||
const b = await createTestPaseoDaemon({
|
||||
agentClients: createTestAgentClients({
|
||||
closeSession: async () => {
|
||||
closes++;
|
||||
reached();
|
||||
await gate;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const sourceClient = createHostClient(`127.0.0.1:${daemonHandle.port}`, {
|
||||
clientId: crypto.randomUUID(),
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
const targetClient = createHostClient(`127.0.0.1:${b.port}`, {
|
||||
clientId: crypto.randomUUID(),
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
await Promise.all([sourceClient.connect(), targetClient.connect()]);
|
||||
const source = createPaseoApi(sourceClient);
|
||||
const target = createPaseoApi(targetClient);
|
||||
try {
|
||||
const agent = await target.agents.create({
|
||||
cwd: b.staticDir,
|
||||
title: "Uncertain close",
|
||||
config: { provider: "codex/test" },
|
||||
});
|
||||
const registration = await source.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await source.hosts.list()).hosts.find(
|
||||
(host) => host.registrationId === registration.registrationId,
|
||||
)?.status,
|
||||
)
|
||||
.toBe("online");
|
||||
const closing = topLevelClient.callTool({
|
||||
name: "kill_agent",
|
||||
args: { serverId: agent.serverId, agentId: agent.id },
|
||||
});
|
||||
void closing.catch(() => {});
|
||||
await ready;
|
||||
await source.hosts.update(registration.registrationId, { enabled: false });
|
||||
const failure = await closing;
|
||||
expect(failure.isError).toBe(true);
|
||||
expect(getStructuredContent(failure)).toMatchObject({
|
||||
serverId: agent.serverId,
|
||||
code: "DAEMON_CONNECTION_LOST",
|
||||
outcome: "unknown",
|
||||
});
|
||||
release();
|
||||
await expect.poll(async () => (await agent.refresh())?.agent.status).toBe("closed");
|
||||
expect(closes).toBe(1);
|
||||
} finally {
|
||||
release();
|
||||
await Promise.allSettled([source.dispose(), target.dispose()]);
|
||||
await Promise.allSettled([sourceClient.close(), targetClient.close()]);
|
||||
await b.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("MCP provider discovery uses the selected host profiles and models", async () => {
|
||||
const { createHostClient } = await import("@getpaseo/client/node");
|
||||
const { createPaseoApi } = await import("@getpaseo/client");
|
||||
const profile = { ...seededAgentProfile, id: "destination-profile", name: "Destination work" };
|
||||
const b = await createTestPaseoDaemon({ agentProfiles: [profile] });
|
||||
const client = createHostClient(`127.0.0.1:${daemonHandle.port}`, {
|
||||
clientId: crypto.randomUUID(),
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
await client.connect();
|
||||
const source = createPaseoApi(client);
|
||||
try {
|
||||
const registration = await source.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await source.hosts.list()).hosts.find(
|
||||
(host) => host.registrationId === registration.registrationId,
|
||||
)?.status,
|
||||
)
|
||||
.toBe("online");
|
||||
const serverId = b.daemon.getServerId();
|
||||
expect(await callToolStructured(topLevelClient, "list_profiles", { serverId })).toMatchObject({
|
||||
serverId,
|
||||
profiles: [profile],
|
||||
});
|
||||
expect(await callToolStructured(topLevelClient, "list_providers", { serverId })).toMatchObject({
|
||||
serverId,
|
||||
providers: expect.arrayContaining([expect.objectContaining({ id: "codex" })]),
|
||||
});
|
||||
expect(
|
||||
await callToolStructured(topLevelClient, "list_models", { serverId, provider: "codex" }),
|
||||
).toMatchObject({ serverId, provider: "codex", models: expect.any(Array) });
|
||||
const inspected = await callToolStructured(topLevelClient, "inspect_provider", {
|
||||
serverId,
|
||||
provider: "codex",
|
||||
cwd: b.staticDir,
|
||||
});
|
||||
expect(inspected.error).toBeUndefined();
|
||||
expect(inspected).toMatchObject({
|
||||
serverId,
|
||||
provider: "codex",
|
||||
enabled: true,
|
||||
features: expect.any(Array),
|
||||
});
|
||||
} finally {
|
||||
await source.dispose();
|
||||
await client.close();
|
||||
await b.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -33,7 +33,6 @@ import { createProviderSnapshotManagerStub } from "../test-utils/session-stubs.j
|
||||
import {
|
||||
AgentListItemPayloadSchema,
|
||||
AgentPermissionRequestPayloadSchema,
|
||||
AgentSnapshotPayloadSchema,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
@@ -5321,87 +5320,6 @@ describe("update_agent MCP tool", () => {
|
||||
|
||||
expect(lookupTool(server, "set_agent_feature")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("updates runtime settings before metadata", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_agent");
|
||||
const input = {
|
||||
agentId: "agent-1",
|
||||
name: "Updated agent",
|
||||
labels: { role: "worker" },
|
||||
settings: {
|
||||
modeId: "full-access",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
features: { fast_mode: true },
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync(input);
|
||||
expect(parsed.success).toBe(true);
|
||||
|
||||
const response = await tool.handler(input);
|
||||
|
||||
expect(spies.agentManager.setAgentMode).toHaveBeenCalledWith("agent-1", "full-access");
|
||||
expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4");
|
||||
expect(spies.agentManager.setAgentThinkingOption).toHaveBeenCalledWith("agent-1", "high");
|
||||
expect(spies.agentManager.setAgentFeature).toHaveBeenCalledWith("agent-1", "fast_mode", true);
|
||||
expect(spies.agentManager.updateAgentMetadata).toHaveBeenCalledWith("agent-1", {
|
||||
title: "Updated agent",
|
||||
labels: { role: "worker" },
|
||||
});
|
||||
expect(response.structuredContent).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("reports success for a no-op update with neither metadata nor settings", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_agent");
|
||||
|
||||
const response = await tool.handler({ agentId: "agent-1" });
|
||||
|
||||
expect(response.structuredContent).toEqual({ success: true });
|
||||
expect(spies.agentManager.updateAgentMetadata).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setAgentMode).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setAgentModel).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setAgentThinkingOption).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setAgentFeature).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not update metadata when runtime settings fail", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.setAgentFeature.mockRejectedValue(new Error("unsupported feature"));
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_agent");
|
||||
|
||||
await expect(
|
||||
tool.handler({
|
||||
agentId: "agent-1",
|
||||
name: "Should not persist",
|
||||
labels: { role: "worker" },
|
||||
settings: { features: { fast_mode: true } },
|
||||
}),
|
||||
).rejects.toThrow("unsupported feature");
|
||||
|
||||
expect(spies.agentStorage.get).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.updateAgentMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rename_workspace MCP tool", () => {
|
||||
@@ -6779,6 +6697,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
parent: null,
|
||||
labels: { role: "researcher" },
|
||||
},
|
||||
],
|
||||
@@ -6791,172 +6710,6 @@ describe("agent snapshot MCP serialization", () => {
|
||||
expect(structured.agents[0]).not.toHaveProperty("pendingPermissions");
|
||||
});
|
||||
|
||||
it("returns archived agent snapshots from storage for get_agent_status", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const record = createStoredRecord({
|
||||
id: "archived-agent",
|
||||
archivedAt: "2026-04-12T00:00:00.000Z",
|
||||
});
|
||||
spies.agentManager.getAgent.mockReturnValue(null);
|
||||
spies.agentStorage.get.mockResolvedValue(record);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger,
|
||||
providerSnapshotManager: createClaudeOnlyManager(),
|
||||
});
|
||||
const tool = registeredTool(server, "get_agent_status");
|
||||
const response = await tool.handler({ agentId: "archived-agent" });
|
||||
|
||||
expect(response.structuredContent).toEqual({
|
||||
status: "closed",
|
||||
snapshot: expect.objectContaining({
|
||||
id: "archived-agent",
|
||||
archivedAt: "2026-04-12T00:00:00.000Z",
|
||||
title: "Stored agent",
|
||||
status: "closed",
|
||||
}),
|
||||
});
|
||||
expect(spies.agentStorage.get).toHaveBeenCalledWith("archived-agent");
|
||||
});
|
||||
|
||||
it("returns full-detail snapshots from get_agent_status", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentStorage.get.mockResolvedValue({ title: "Full detail agent" });
|
||||
spies.agentManager.getAgent.mockReturnValue(
|
||||
createManagedAgent({
|
||||
id: "full-detail-agent",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/full-detail",
|
||||
config: { model: "gpt-5.4", thinkingOptionId: "high" },
|
||||
runtimeInfo: {
|
||||
provider: "codex",
|
||||
sessionId: "session-full",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "xhigh",
|
||||
modeId: "auto",
|
||||
},
|
||||
currentModeId: "auto",
|
||||
availableModes: [
|
||||
{
|
||||
id: "auto",
|
||||
label: "Auto",
|
||||
description: "Default coding mode",
|
||||
},
|
||||
],
|
||||
features: [
|
||||
{
|
||||
type: "toggle",
|
||||
id: "web-search",
|
||||
label: "Web search",
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
pendingPermissions: new Map(),
|
||||
persistence: {
|
||||
provider: "codex",
|
||||
sessionId: "session-full",
|
||||
},
|
||||
capabilities: {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsSessionListing: true,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: true,
|
||||
supportsNewProviderCapability: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "get_agent_status");
|
||||
const response = await tool.handler({ agentId: "full-detail-agent" });
|
||||
const snapshot = z.record(z.string(), z.unknown()).parse(response.structuredContent.snapshot);
|
||||
|
||||
const parsed = AgentSnapshotPayloadSchema.safeParse(snapshot);
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`get_agent_status response failed AgentSnapshotPayloadSchema: ${JSON.stringify(parsed.error.issues, null, 2)}`,
|
||||
);
|
||||
}
|
||||
expect(response.structuredContent.status).toBe("idle");
|
||||
expect(snapshot).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "full-detail-agent",
|
||||
title: "Full detail agent",
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
effectiveThinkingOptionId: "xhigh",
|
||||
currentModeId: "auto",
|
||||
runtimeInfo: {
|
||||
provider: "codex",
|
||||
sessionId: "session-full",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "xhigh",
|
||||
modeId: "auto",
|
||||
},
|
||||
persistence: {
|
||||
provider: "codex",
|
||||
sessionId: "session-full",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(snapshot.capabilities).toEqual(
|
||||
expect.objectContaining({
|
||||
supportsMcpServers: true,
|
||||
supportsToolInvocations: true,
|
||||
}),
|
||||
);
|
||||
expect(snapshot.availableModes).toEqual([
|
||||
{
|
||||
id: "auto",
|
||||
label: "Auto",
|
||||
description: "Default coding mode",
|
||||
},
|
||||
]);
|
||||
expect(snapshot.features).toEqual([
|
||||
{
|
||||
type: "toggle",
|
||||
id: "web-search",
|
||||
label: "Web search",
|
||||
value: true,
|
||||
},
|
||||
]);
|
||||
expect(snapshot.pendingPermissions).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not expose internal stored agents from get_agent_status", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue(null);
|
||||
spies.agentStorage.get.mockResolvedValue(
|
||||
createStoredRecord({
|
||||
id: "internal-agent",
|
||||
internal: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger,
|
||||
providerSnapshotManager: createClaudeOnlyManager(),
|
||||
});
|
||||
const tool = registeredTool(server, "get_agent_status");
|
||||
|
||||
await expect(tool.handler({ agentId: "internal-agent" })).rejects.toThrow(
|
||||
"Agent internal-agent not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults list_agents to caller cwd and excludes archived agents", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const now = new Date().toISOString();
|
||||
@@ -7144,6 +6897,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
parent: null,
|
||||
labels: {},
|
||||
});
|
||||
expect(item).not.toHaveProperty("features");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -9,11 +10,50 @@ import {
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { writeJsonFileAtomic } from "../../atomic-file.js";
|
||||
|
||||
const StoredRunSchema = AgentRunRecordSchema.extend({
|
||||
admission: z.enum(["pending", "rejected", "accepted"]).optional(),
|
||||
inputFingerprint: z.string().optional(),
|
||||
});
|
||||
type StoredRun = z.infer<typeof StoredRunSchema>;
|
||||
|
||||
/** Durable submission/turn identity and checkpoints; idle agent state is never a run result. */
|
||||
export class AgentRuns {
|
||||
private readonly records = new Map<string, AgentRunRecord>();
|
||||
private readonly submissionsByTurn = new Map<string, string>();
|
||||
private readonly records = new Map<string, StoredRun>();
|
||||
private readonly submissionsByTurn = new Map<string, Set<string>>();
|
||||
private readonly loadedAgents = new Set<string>();
|
||||
private readonly observers = new Map<string, Set<(record: AgentRunRecord | null) => void>>();
|
||||
observe(
|
||||
agentId: string,
|
||||
submissionId: string,
|
||||
snapshot: (record: AgentRunRecord | null) => void,
|
||||
changed: (record: AgentRunRecord | null) => void,
|
||||
): Promise<() => void> {
|
||||
return this.mutate(async () => {
|
||||
const key = this.identity(agentId, submissionId);
|
||||
const record = await this.read(agentId, submissionId);
|
||||
const observers = this.observers.get(key) ?? new Set();
|
||||
observers.add(changed);
|
||||
this.observers.set(key, observers);
|
||||
try {
|
||||
snapshot(record ? AgentRunRecordSchema.parse(record) : null);
|
||||
} catch (error) {
|
||||
observers.delete(changed);
|
||||
if (!observers.size) this.observers.delete(key);
|
||||
throw error;
|
||||
}
|
||||
return () => {
|
||||
observers.delete(changed);
|
||||
if (!observers.size) this.observers.delete(key);
|
||||
};
|
||||
});
|
||||
}
|
||||
private publish(agentId: string): void {
|
||||
for (const record of this.records.values()) {
|
||||
if (record.agent.agentId !== agentId) continue;
|
||||
for (const changed of this.observers.get(this.identity(agentId, record.submissionId)) ?? [])
|
||||
changed(AgentRunRecordSchema.parse(record));
|
||||
}
|
||||
}
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
constructor(private readonly options: { serverId: string; directory?: string }) {}
|
||||
|
||||
@@ -22,11 +62,11 @@ export class AgentRuns {
|
||||
}
|
||||
private async load(agentId: string): Promise<void> {
|
||||
if (this.loadedAgents.has(agentId)) return;
|
||||
let records: AgentRunRecord[] = [];
|
||||
let records: StoredRun[] = [];
|
||||
if (this.options.directory) {
|
||||
try {
|
||||
const saved: unknown = JSON.parse(await readFile(this.path(agentId), "utf8"));
|
||||
records = AgentRunRecordSchema.array().parse(saved);
|
||||
records = StoredRunSchema.array().parse(saved);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
@@ -41,13 +81,13 @@ export class AgentRuns {
|
||||
});
|
||||
this.records.set(this.identity(agentId, record.submissionId), record);
|
||||
if (record.turnId && !terminal(record))
|
||||
this.submissionsByTurn.set(this.identity(agentId, record.turnId), record.submissionId);
|
||||
this.bind(agentId, record.turnId, record.submissionId);
|
||||
}
|
||||
if (records.length && this.options.directory)
|
||||
await writeJsonFileAtomic(this.path(agentId), records);
|
||||
this.loadedAgents.add(agentId);
|
||||
}
|
||||
private async read(agentId: string, submissionId: string): Promise<AgentRunRecord | null> {
|
||||
private async read(agentId: string, submissionId: string): Promise<StoredRun | null> {
|
||||
await this.load(agentId);
|
||||
const record = this.records.get(this.identity(agentId, submissionId));
|
||||
return record ? structuredClone(record) : null;
|
||||
@@ -63,11 +103,16 @@ export class AgentRuns {
|
||||
const records = [...this.records.values()]
|
||||
.filter((record) => record.agent.agentId === agentId)
|
||||
.map((record) => Object.assign({}, record, { durability: "persisted" as const }));
|
||||
if (this.options.directory) await writeJsonFileAtomic(this.path(agentId), records);
|
||||
for (const record of records)
|
||||
this.records.set(this.identity(agentId, record.submissionId), record);
|
||||
try {
|
||||
if (this.options.directory) await writeJsonFileAtomic(this.path(agentId), records);
|
||||
for (const record of records)
|
||||
this.records.set(this.identity(agentId, record.submissionId), record);
|
||||
} finally {
|
||||
// Publish accepted evidence with its current durability even when storage fails.
|
||||
this.publish(agentId);
|
||||
}
|
||||
}
|
||||
private async write(record: AgentRunRecord, retainEvidence = false): Promise<void> {
|
||||
private async write(record: StoredRun, retainEvidence = false): Promise<void> {
|
||||
const key = this.identity(record.agent.agentId, record.submissionId);
|
||||
const previous = this.records.get(key);
|
||||
this.records.set(key, { ...record, durability: "pending" });
|
||||
@@ -98,12 +143,24 @@ export class AgentRuns {
|
||||
// read or checkpoint retries the same atomic collection, without replay.
|
||||
await this.persistAgent(agentId).catch(() => {});
|
||||
}
|
||||
return this.read(agentId, submissionId);
|
||||
const record = await this.read(agentId, submissionId);
|
||||
return record ? AgentRunRecordSchema.parse(record) : null;
|
||||
});
|
||||
}
|
||||
prepare(agentId: string, submissionId: string): Promise<void> {
|
||||
prepare(agentId: string, submissionId: string, input: unknown = null): Promise<void> {
|
||||
return this.mutate(async () => {
|
||||
if (await this.read(agentId, submissionId))
|
||||
const existing = await this.read(agentId, submissionId);
|
||||
const inputFingerprint = createHash("sha256").update(JSON.stringify(input)).digest("hex");
|
||||
if (
|
||||
existing?.admission === "rejected" &&
|
||||
existing.status === "submitted" &&
|
||||
existing.turnId === null &&
|
||||
existing.inputFingerprint === inputFingerprint
|
||||
) {
|
||||
await this.write({ ...existing, admission: "pending" });
|
||||
return;
|
||||
}
|
||||
if (existing)
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
"This run submission already exists; inspect it before submitting different work",
|
||||
@@ -114,11 +171,27 @@ export class AgentRuns {
|
||||
agent: { serverId: this.options.serverId, agentId },
|
||||
submissionId,
|
||||
turnId: null,
|
||||
admission: "pending",
|
||||
inputFingerprint,
|
||||
status: "submitted",
|
||||
checkpoints: [{ sequence: 1, kind: "submitted", at: new Date().toISOString() }],
|
||||
});
|
||||
});
|
||||
}
|
||||
/** The admission owner calls this only for a definitive pre-admission rejection. */
|
||||
rejectUnadmitted(agentId: string, submissionId: string): Promise<void> {
|
||||
return this.mutate(async () => {
|
||||
const record = await this.read(agentId, submissionId);
|
||||
if (
|
||||
!record ||
|
||||
record.status !== "submitted" ||
|
||||
record.turnId !== null ||
|
||||
record.admission !== "pending"
|
||||
)
|
||||
throw new Error("Cannot reject an admission that is no longer pending");
|
||||
await this.write({ ...record, admission: "rejected" }, true);
|
||||
});
|
||||
}
|
||||
start(agentId: string, submissionId: string, turnId: string): Promise<void> {
|
||||
return this.mutate(async () => {
|
||||
const record = await this.read(agentId, submissionId);
|
||||
@@ -126,11 +199,39 @@ export class AgentRuns {
|
||||
if (record.turnId !== null && record.turnId !== turnId)
|
||||
throw new Error("Run submission cannot bind to a different provider turn");
|
||||
record.turnId = turnId;
|
||||
record.admission = "accepted";
|
||||
this.append(record, { kind: "running" });
|
||||
// Admission has happened. Retain its binding even if durable storage fails;
|
||||
// subsequent provider evidence can retry persistence without replaying work.
|
||||
this.records.set(this.identity(agentId, submissionId), record);
|
||||
this.submissionsByTurn.set(this.identity(agentId, turnId), submissionId);
|
||||
this.bind(agentId, turnId, submissionId);
|
||||
await this.write(record, true);
|
||||
});
|
||||
}
|
||||
private bind(agentId: string, turnId: string, submissionId: string): void {
|
||||
const key = this.identity(agentId, turnId);
|
||||
const submissions = this.submissionsByTurn.get(key) ?? new Set<string>();
|
||||
submissions.add(submissionId);
|
||||
this.submissionsByTurn.set(key, submissions);
|
||||
}
|
||||
/** Called only after the provider accepted a steer, before staged events drain. */
|
||||
acceptSteer(agentId: string, submissionId: string, turnId: string): Promise<void> {
|
||||
return this.mutate(async () => {
|
||||
const existing = await this.read(agentId, submissionId);
|
||||
if (existing) {
|
||||
if (existing.turnId !== turnId)
|
||||
throw new Error("Run submission already belongs to another turn");
|
||||
return;
|
||||
}
|
||||
const record: AgentRunRecord = {
|
||||
agent: { serverId: this.options.serverId, agentId },
|
||||
submissionId,
|
||||
turnId,
|
||||
status: "submitted",
|
||||
checkpoints: [{ sequence: 1, kind: "submitted", at: new Date().toISOString() }],
|
||||
};
|
||||
this.append(record, { kind: "running" });
|
||||
this.bind(agentId, turnId, submissionId);
|
||||
await this.write(record, true);
|
||||
});
|
||||
}
|
||||
@@ -145,22 +246,37 @@ export class AgentRuns {
|
||||
if (!checkpoint) return Promise.resolve();
|
||||
return this.mutate(async () => {
|
||||
await this.load(agentId);
|
||||
const submissionId = this.submissionsByTurn.get(this.identity(agentId, turnId));
|
||||
if (!submissionId) return;
|
||||
const record = await this.read(agentId, submissionId);
|
||||
if (!record || terminal(record)) return;
|
||||
if (
|
||||
checkpoint.permissionRequestId &&
|
||||
record.checkpoints.some(
|
||||
(existing) =>
|
||||
existing.kind === checkpoint.kind &&
|
||||
existing.permissionRequestId === checkpoint.permissionRequestId,
|
||||
const submissions = this.submissionsByTurn.get(this.identity(agentId, turnId));
|
||||
if (!submissions) return;
|
||||
let changed = false;
|
||||
for (const submissionId of submissions) {
|
||||
const record = await this.read(agentId, submissionId);
|
||||
if (!record || terminal(record)) continue;
|
||||
if (
|
||||
checkpoint.permissionRequestId &&
|
||||
record.checkpoints.some(
|
||||
(existing) =>
|
||||
existing.kind === checkpoint.kind &&
|
||||
existing.permissionRequestId === checkpoint.permissionRequestId,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
this.append(record, checkpoint);
|
||||
this.records.set(this.identity(agentId, submissionId), {
|
||||
...record,
|
||||
durability: "pending",
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
if (
|
||||
[...submissions].every((id) => {
|
||||
const record = this.records.get(this.identity(agentId, id));
|
||||
return !record || terminal(record);
|
||||
})
|
||||
)
|
||||
return;
|
||||
this.append(record, checkpoint);
|
||||
if (terminal(record)) this.submissionsByTurn.delete(this.identity(agentId, turnId));
|
||||
await this.write(record, true);
|
||||
this.submissionsByTurn.delete(this.identity(agentId, turnId));
|
||||
// Retain evidence for every accepted submission even if this write fails.
|
||||
if (changed) await this.persistAgent(agentId);
|
||||
});
|
||||
}
|
||||
failAdmission(agentId: string, submissionId: string, error: string): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { HostRuntime, PaseoApi } from "@getpaseo/client";
|
||||
import { z } from "zod";
|
||||
import type { PaseoToolResult } from "./types.js";
|
||||
|
||||
/** Resolves one ordinary API scope and preserves operation evidence through cleanup and MCP serialization. */
|
||||
export async function executeHostTool(
|
||||
resolveRuntime: () => Promise<HostRuntime>,
|
||||
requestedServerId: string | undefined,
|
||||
execute: (
|
||||
api: PaseoApi,
|
||||
identity: { serverId: string; originServerId: string },
|
||||
) => Promise<PaseoToolResult>,
|
||||
): Promise<PaseoToolResult> {
|
||||
let serverId = requestedServerId;
|
||||
try {
|
||||
const runtime = await resolveRuntime();
|
||||
const originServerId = (await runtime.directory()).self.serverId;
|
||||
serverId ??= originServerId;
|
||||
const api = await runtime.client(serverId);
|
||||
let result: PaseoToolResult;
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
result = await execute(api, { serverId, originServerId });
|
||||
payload = z.record(z.string(), z.unknown()).parse(result.structuredContent);
|
||||
} catch (error) {
|
||||
// The action owns its outcome. Failed cleanup cannot replace evidence that
|
||||
// a submitted operation has an unknown outcome.
|
||||
await api.dispose().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
let cleanupError: ReturnType<typeof describeError> | undefined;
|
||||
try {
|
||||
await api.dispose();
|
||||
} catch (error) {
|
||||
cleanupError = describeError(error);
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
structuredContent: {
|
||||
...payload,
|
||||
serverId,
|
||||
...(cleanupError ? { scopeCleanupError: cleanupError } : {}),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [],
|
||||
isError: true,
|
||||
structuredContent: { ...(serverId ? { serverId } : {}), ...describeError(error) },
|
||||
};
|
||||
}
|
||||
}
|
||||
function describeError(error: unknown): {
|
||||
error: string;
|
||||
code?: string;
|
||||
outcome?: "unknown";
|
||||
cause?: string;
|
||||
} {
|
||||
const details = error instanceof Error ? error : new Error(String(error));
|
||||
return {
|
||||
error: details.message,
|
||||
...("code" in details && typeof details.code === "string" ? { code: details.code } : {}),
|
||||
...("outcome" in details && details.outcome === "unknown"
|
||||
? { outcome: "unknown" as const }
|
||||
: {}),
|
||||
...("cause" in details && typeof details.cause === "string" ? { cause: details.cause } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { executeHostTool } from "./host-tool.js";
|
||||
import { AgentDirectory } from "../directory.js";
|
||||
import type { HostRuntime } from "@getpaseo/client";
|
||||
import type { HostRuntime, PaseoApi } from "@getpaseo/client";
|
||||
import { HostDirectorySchema } from "@getpaseo/protocol/hosts";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { z } from "zod";
|
||||
@@ -77,13 +78,6 @@ import {
|
||||
waitForAgentRunStartWithTimeout,
|
||||
} from "../agent-prompt.js";
|
||||
import { respondToAgentPermission } from "../permission-response.js";
|
||||
import {
|
||||
archiveAgentCommand,
|
||||
cancelAgentRunCommand,
|
||||
closeAgentCommand,
|
||||
setAgentModeCommand,
|
||||
updateAgentCommand,
|
||||
} from "../lifecycle-command.js";
|
||||
import type { ForgeService } from "../../../services/forge-service.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
import type {
|
||||
@@ -661,7 +655,6 @@ function createToolCatalog(
|
||||
workspaceScripts,
|
||||
scheduleService,
|
||||
providerSnapshotManager,
|
||||
daemonConfigStore,
|
||||
callerAgentId,
|
||||
resolveSpeakHandler,
|
||||
resolveCallerContext,
|
||||
@@ -707,6 +700,45 @@ function createToolCatalog(
|
||||
handler: handler as PaseoToolDefinition["handler"],
|
||||
});
|
||||
};
|
||||
const registerHostTool = <T extends z.ZodRawShape>(
|
||||
name: string,
|
||||
config: Omit<PaseoToolConfig, "inputSchema"> & { inputSchema: T },
|
||||
handler: (
|
||||
input: z.output<z.ZodObject<T>>,
|
||||
api: PaseoApi,
|
||||
context: PaseoToolExecutionContext,
|
||||
) => Promise<PaseoToolResult>,
|
||||
) => {
|
||||
registerTool(
|
||||
name,
|
||||
{
|
||||
...config,
|
||||
inputSchema: {
|
||||
...config.inputSchema,
|
||||
serverId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Destination server ID from list_hosts. Omit for this daemon."),
|
||||
},
|
||||
outputSchema: { ...config.outputSchema, serverId: z.string() },
|
||||
},
|
||||
async ({ serverId, ...input }, context) => {
|
||||
if (!options.hostRuntime) throw new Error("Host runtime is not configured");
|
||||
return executeHostTool(options.hostRuntime, serverId, (api, identity) => {
|
||||
const normalized = { ...input };
|
||||
if (identity.originServerId !== identity.serverId && callerContext?.lockedCwd)
|
||||
throw Object.assign(
|
||||
new PaseoToolInputError("This caller is restricted to its origin workspace"),
|
||||
{ code: "access_denied" },
|
||||
);
|
||||
if ("cwd" in config.inputSchema && identity.originServerId === identity.serverId)
|
||||
normalized.cwd = resolveScopedCwd(normalized.cwd);
|
||||
return handler(normalized as z.output<z.ZodObject<T>>, api, context);
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
const toCatalog = (): PaseoToolCatalog => ({
|
||||
tools,
|
||||
getTool(name: string): PaseoToolDefinition | undefined {
|
||||
@@ -2096,7 +2128,7 @@ function createToolCatalog(
|
||||
},
|
||||
);
|
||||
|
||||
registerTool(
|
||||
registerHostTool(
|
||||
"get_agent_status",
|
||||
{
|
||||
title: "Get agent status",
|
||||
@@ -2110,37 +2142,15 @@ function createToolCatalog(
|
||||
snapshot: AgentSnapshotPayloadSchema,
|
||||
},
|
||||
},
|
||||
async ({ agentId }) => {
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
if (snapshot) {
|
||||
const structuredSnapshot = await serializeSnapshotWithMetadata(
|
||||
agentStorage,
|
||||
snapshot,
|
||||
childLogger,
|
||||
);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
status: snapshot.lifecycle,
|
||||
snapshot: structuredSnapshot,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const record = await agentStorage.get(agentId);
|
||||
if (!record || record.internal) {
|
||||
async ({ agentId }, api) => {
|
||||
const refreshed = await api.agents.ref(agentId).refresh();
|
||||
if (!refreshed || refreshed.agent.internal)
|
||||
throw new PaseoToolInputError(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const structuredSnapshot = buildStoredAgentPayload(
|
||||
record,
|
||||
new Set(providerSnapshotManager.listRegisteredProviderIds()),
|
||||
);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
status: structuredSnapshot.status,
|
||||
snapshot: structuredSnapshot,
|
||||
status: refreshed.agent.status,
|
||||
snapshot: refreshed.agent,
|
||||
}),
|
||||
};
|
||||
},
|
||||
@@ -2215,7 +2225,7 @@ function createToolCatalog(
|
||||
},
|
||||
);
|
||||
|
||||
registerTool(
|
||||
registerHostTool(
|
||||
"cancel_agent",
|
||||
{
|
||||
title: "Cancel agent run",
|
||||
@@ -2227,19 +2237,13 @@ function createToolCatalog(
|
||||
success: z.boolean(),
|
||||
},
|
||||
},
|
||||
async ({ agentId }) => {
|
||||
const { cancelled } = await cancelAgentRunCommand(
|
||||
{ agentManager, logger: childLogger },
|
||||
agentId,
|
||||
);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ success: cancelled }),
|
||||
};
|
||||
async ({ agentId }, api) => {
|
||||
const { cancelled } = await api.agents.ref(agentId).cancel();
|
||||
return { content: [], structuredContent: { success: cancelled } };
|
||||
},
|
||||
);
|
||||
|
||||
registerTool(
|
||||
registerHostTool(
|
||||
"archive_agent",
|
||||
{
|
||||
title: "Archive agent",
|
||||
@@ -2252,23 +2256,13 @@ function createToolCatalog(
|
||||
success: z.boolean(),
|
||||
},
|
||||
},
|
||||
async ({ agentId }) => {
|
||||
await archiveAgentCommand(
|
||||
{
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger: childLogger,
|
||||
},
|
||||
agentId,
|
||||
);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ success: true }),
|
||||
};
|
||||
async ({ agentId }, api) => {
|
||||
await api.agents.ref(agentId).archive();
|
||||
return { content: [], structuredContent: { success: true } };
|
||||
},
|
||||
);
|
||||
|
||||
registerTool(
|
||||
registerHostTool(
|
||||
"kill_agent",
|
||||
{
|
||||
title: "Kill agent",
|
||||
@@ -2280,16 +2274,13 @@ function createToolCatalog(
|
||||
success: z.boolean(),
|
||||
},
|
||||
},
|
||||
async ({ agentId }) => {
|
||||
await closeAgentCommand({ agentManager }, agentId);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ success: true }),
|
||||
};
|
||||
async ({ agentId }, api) => {
|
||||
await api.agents.ref(agentId).close();
|
||||
return { content: [], structuredContent: { success: true } };
|
||||
},
|
||||
);
|
||||
|
||||
registerTool(
|
||||
registerHostTool(
|
||||
"update_agent",
|
||||
{
|
||||
title: "Update agent",
|
||||
@@ -2306,28 +2297,17 @@ function createToolCatalog(
|
||||
success: z.boolean(),
|
||||
},
|
||||
},
|
||||
async ({ agentId, name, labels, settings }) => {
|
||||
if (settings?.modeId !== undefined) {
|
||||
await agentManager.setAgentMode(agentId, settings.modeId);
|
||||
}
|
||||
if (settings?.model !== undefined) {
|
||||
await agentManager.setAgentModel(agentId, settings.model);
|
||||
}
|
||||
if (settings?.thinkingOptionId !== undefined) {
|
||||
await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId);
|
||||
}
|
||||
if (settings?.features) {
|
||||
for (const [featureId, value] of Object.entries(settings.features)) {
|
||||
await agentManager.setAgentFeature(agentId, featureId, value);
|
||||
}
|
||||
}
|
||||
|
||||
await updateAgentCommand({ agentManager }, { agentId, name, labels });
|
||||
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ success: true }),
|
||||
};
|
||||
async ({ agentId, name, labels, settings }, api) => {
|
||||
const agent = api.agents.ref(agentId);
|
||||
if (settings)
|
||||
await agent.configure({
|
||||
modeId: settings.modeId,
|
||||
modelId: settings.model,
|
||||
thinkingOptionId: settings.thinkingOptionId,
|
||||
featureValues: settings.features,
|
||||
});
|
||||
if (name !== undefined || labels !== undefined) await agent.update({ name, labels });
|
||||
return { content: [], structuredContent: { success: true } };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3040,7 +3020,7 @@ function createToolCatalog(
|
||||
},
|
||||
);
|
||||
|
||||
registerTool(
|
||||
registerHostTool(
|
||||
"list_providers",
|
||||
{
|
||||
title: "List providers",
|
||||
@@ -3050,18 +3030,13 @@ function createToolCatalog(
|
||||
providers: z.array(ProviderSummarySchema),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
const providers = (await providerSnapshotManager.listProviders({ wait: true })).map(
|
||||
toProviderSummary,
|
||||
);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ providers }),
|
||||
};
|
||||
async (_input, api) => {
|
||||
const providers = (await api.providers.waitForReady()).entries.map(toProviderSummary);
|
||||
return { content: [], structuredContent: ensureValidJson({ providers }) };
|
||||
},
|
||||
);
|
||||
|
||||
registerTool(
|
||||
registerHostTool(
|
||||
"list_models",
|
||||
{
|
||||
title: "List models",
|
||||
@@ -3074,22 +3049,17 @@ function createToolCatalog(
|
||||
models: z.array(AgentModelSchema),
|
||||
},
|
||||
},
|
||||
async ({ provider }) => {
|
||||
const models = await providerSnapshotManager.listModels({
|
||||
provider,
|
||||
wait: true,
|
||||
});
|
||||
async ({ provider }, api) => {
|
||||
const result = await api.providers.listModels(provider);
|
||||
if (result.error) throw new PaseoToolInputError(result.error);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
provider,
|
||||
models,
|
||||
}),
|
||||
structuredContent: ensureValidJson({ provider, models: result.models ?? [] }),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
registerTool(
|
||||
registerHostTool(
|
||||
"list_profiles",
|
||||
{
|
||||
title: "List agent profiles",
|
||||
@@ -3104,16 +3074,13 @@ function createToolCatalog(
|
||||
profiles: z.array(AgentProfileSchema),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
const profiles = daemonConfigStore?.get().agentProfiles ?? [];
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ profiles }),
|
||||
};
|
||||
async (_input, api) => {
|
||||
const profiles = (await api.config.get()).config.agentProfiles ?? [];
|
||||
return { content: [], structuredContent: ensureValidJson({ profiles }) };
|
||||
},
|
||||
);
|
||||
|
||||
registerTool(
|
||||
registerHostTool(
|
||||
"inspect_provider",
|
||||
{
|
||||
title: "Inspect provider",
|
||||
@@ -3131,34 +3098,27 @@ function createToolCatalog(
|
||||
features: z.array(AgentFeatureSchema),
|
||||
},
|
||||
},
|
||||
async ({ provider, cwd, settings }) => {
|
||||
const resolvedProviderModel = resolveScheduleProviderAndModel({
|
||||
provider,
|
||||
defaultProvider: provider,
|
||||
});
|
||||
const providerId = resolvedProviderModel.provider;
|
||||
const resolvedCwd = resolveScopedCwd(cwd, { required: true });
|
||||
const entry = await providerSnapshotManager.getProvider({
|
||||
cwd: resolvedCwd,
|
||||
provider: providerId,
|
||||
wait: true,
|
||||
});
|
||||
const summary = toProviderSummary(entry);
|
||||
if (!entry.enabled) {
|
||||
throw new PaseoToolInputError(`Provider '${providerId}' is disabled`);
|
||||
}
|
||||
if (entry.status !== "ready") {
|
||||
async ({ provider, cwd, settings }, api) => {
|
||||
if (!cwd) throw new PaseoToolInputError("Choose a working directory on the destination host");
|
||||
const selection = resolveScheduleProviderAndModel({ provider, defaultProvider: provider });
|
||||
const providerId = selection.provider;
|
||||
const entry = (await api.providers.waitForReady({ cwd })).entries.find(
|
||||
(item) => item.provider === providerId,
|
||||
);
|
||||
if (!entry) throw new PaseoToolInputError(`Provider '${providerId}' was not found`);
|
||||
if (!entry.enabled) throw new PaseoToolInputError(`Provider '${providerId}' is disabled`);
|
||||
if (entry.status !== "ready")
|
||||
throw new PaseoToolInputError(entry.error ?? `Provider '${providerId}' is unavailable`);
|
||||
}
|
||||
const selectedModel = settings?.model ?? resolvedProviderModel.model;
|
||||
const features = await agentManager.listDraftFeatures({
|
||||
provider: providerId,
|
||||
cwd: resolvedCwd,
|
||||
...(settings?.modeId ? { modeId: settings.modeId } : {}),
|
||||
...(selectedModel ? { model: selectedModel } : {}),
|
||||
...(settings?.thinkingOptionId ? { thinkingOptionId: settings.thinkingOptionId } : {}),
|
||||
...(settings?.features ? { featureValues: settings.features } : {}),
|
||||
const selectedModel = settings?.model ?? selection.model;
|
||||
const result = await api.providers.listFeatures({
|
||||
provider: selectedModel ? `${providerId}/${selectedModel}` : providerId,
|
||||
cwd,
|
||||
modeId: settings?.modeId,
|
||||
thinkingOptionId: settings?.thinkingOptionId,
|
||||
featureValues: settings?.features,
|
||||
});
|
||||
if (result.error) throw new PaseoToolInputError(result.error);
|
||||
const summary = toProviderSummary(entry);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
@@ -3169,7 +3129,7 @@ function createToolCatalog(
|
||||
status: summary.status,
|
||||
modes: summary.modes,
|
||||
selectedModel: selectedModel ?? null,
|
||||
features,
|
||||
features: result.features ?? [],
|
||||
}),
|
||||
};
|
||||
},
|
||||
@@ -3231,7 +3191,7 @@ function createToolCatalog(
|
||||
},
|
||||
);
|
||||
|
||||
registerTool(
|
||||
registerHostTool(
|
||||
"set_agent_mode",
|
||||
{
|
||||
title: "Set agent session mode",
|
||||
@@ -3246,12 +3206,9 @@ function createToolCatalog(
|
||||
newMode: z.string(),
|
||||
},
|
||||
},
|
||||
async ({ agentId, modeId }) => {
|
||||
const result = await setAgentModeCommand({ agentManager }, { agentId, modeId });
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ success: true, newMode: result.modeId }),
|
||||
};
|
||||
async ({ agentId, modeId }, api) => {
|
||||
await api.agents.ref(agentId).setMode(modeId);
|
||||
return { content: [], structuredContent: { success: true, newMode: modeId } };
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ type OutboundOperation = SessionOutboundMessage["type"];
|
||||
export type PermissionRequirement = DaemonPermission | readonly DaemonPermission[] | null;
|
||||
|
||||
const INBOUND_PERMISSION = {
|
||||
"agent.create.cancel.request": "workspace.write",
|
||||
"agent.close.request": "workspace.write",
|
||||
"creation.cancel.request": "workspace.write",
|
||||
"agent.runs.get.request": "workspace.read",
|
||||
"agent.subagents.detach.request": "workspace.write",
|
||||
"agent.subagents.forget.request": "workspace.write",
|
||||
@@ -230,8 +231,10 @@ const INBOUND_PERMISSION = {
|
||||
} as const satisfies Record<InboundOperation, PermissionRequirement>;
|
||||
|
||||
const OUTBOUND_PERMISSION = {
|
||||
"agent.create.cancel.response": "workspace.write",
|
||||
"agent.close.response": "workspace.write",
|
||||
"creation.cancel.response": "workspace.write",
|
||||
"agent.runs.get.response": "workspace.read",
|
||||
"agent.runs.changed": "workspace.read",
|
||||
"agent.subagents.detach.response": "workspace.write",
|
||||
"agent.subagents.forget.response": "workspace.write",
|
||||
"agent.subagents.create.response": "workspace.write",
|
||||
|
||||
@@ -1377,7 +1377,7 @@ export async function createPaseoDaemon(
|
||||
archiveWorkspace: archiveScheduleWorkspaceExternal,
|
||||
});
|
||||
await scheduleService.start();
|
||||
agentManager.setAgentArchivedCallback(async (agentId) => {
|
||||
const stopScheduleAgentArchived = agentManager.onAgentArchived(async (agentId) => {
|
||||
try {
|
||||
await scheduleService.completeForAgent(agentId);
|
||||
} catch (error) {
|
||||
@@ -1866,6 +1866,7 @@ export async function createPaseoDaemon(
|
||||
await pluginRuntime.stopAllPlugins();
|
||||
terminalManager.killAll();
|
||||
await speechService.stop();
|
||||
stopScheduleAgentArchived();
|
||||
await scheduleService.stop().catch(() => undefined);
|
||||
await relayRuntime?.stop().catch(() => undefined);
|
||||
if (wsServer) {
|
||||
|
||||
@@ -1174,7 +1174,7 @@ test("channel output accounts for escaped text, awaits physical drain, and prese
|
||||
expect(wss.clients.size).toBe(1);
|
||||
});
|
||||
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, rmSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||
test.skipIf(process.platform !== "linux" || !existsSync("/usr/sbin/sshd"))(
|
||||
@@ -2253,12 +2253,15 @@ test("creation cancellation survives destination restart and fences delayed subm
|
||||
const origin = await api(first);
|
||||
const agentId = crypto.randomUUID();
|
||||
const idempotencyKey = "reserved-before-admission";
|
||||
expect(await origin.paseo.creations.cancelAgent({ agentId, idempotencyKey })).toEqual({
|
||||
expect(await origin.paseo.creations.cancel({ kind: "agent", agentId, idempotencyKey })).toEqual({
|
||||
status: "canceled",
|
||||
reservedAgent: { serverId: first.daemon.getServerId(), agentId },
|
||||
reservation: {
|
||||
kind: "agent",
|
||||
reservedAgent: { serverId: first.daemon.getServerId(), agentId },
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
origin.paseo.creations.cancelAgent({ agentId: crypto.randomUUID(), idempotencyKey }),
|
||||
origin.paseo.creations.cancel({ kind: "agent", agentId: crypto.randomUUID(), idempotencyKey }),
|
||||
).rejects.toMatchObject({ code: "agent_request_key_conflict" });
|
||||
const viewer = createHostClient(`127.0.0.1:${first.port}`, {
|
||||
clientId: crypto.randomUUID(),
|
||||
@@ -2270,7 +2273,8 @@ test("creation cancellation survives destination restart and fences delayed subm
|
||||
const viewerApi = createPaseoApi(viewer);
|
||||
cleanup.push(() => viewerApi.dispose());
|
||||
await expect(
|
||||
viewerApi.creations.cancelAgent({
|
||||
viewerApi.creations.cancel({
|
||||
kind: "agent",
|
||||
agentId: crypto.randomUUID(),
|
||||
idempotencyKey: "unauthorized-cancellation",
|
||||
}),
|
||||
@@ -2297,7 +2301,9 @@ test("creation cancellation survives destination restart and fences delayed subm
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "agent_request_key_conflict" });
|
||||
expect(await next.paseo.agents.ref(agentId).refresh()).toBeNull();
|
||||
expect(await next.paseo.creations.cancelAgent({ agentId, idempotencyKey })).toMatchObject({
|
||||
expect(
|
||||
await next.paseo.creations.cancel({ kind: "agent", agentId, idempotencyKey }),
|
||||
).toMatchObject({
|
||||
status: "canceled",
|
||||
});
|
||||
});
|
||||
@@ -2339,68 +2345,99 @@ test("creation cancellation reports admitted work before provider resource readi
|
||||
});
|
||||
void creating.catch(() => {});
|
||||
await ready;
|
||||
const cancellation = await paseo.creations.cancelAgent({ agentId, idempotencyKey });
|
||||
const cancellation = await paseo.creations.cancel({ kind: "agent", agentId, idempotencyKey });
|
||||
expect(cancellation).toMatchObject({
|
||||
status: "admitted",
|
||||
reservedAgent: { serverId: instance.daemon.getServerId(), agentId },
|
||||
reservation: {
|
||||
kind: "agent",
|
||||
reservedAgent: { serverId: instance.daemon.getServerId(), agentId },
|
||||
},
|
||||
creation: { agentId, phase: "accepted" },
|
||||
});
|
||||
expect(cancellation.status === "admitted" && cancellation.creation.agent).toBeUndefined();
|
||||
release();
|
||||
await creating;
|
||||
expect(await paseo.creations.cancelAgent({ agentId, idempotencyKey })).toMatchObject({
|
||||
expect(await paseo.creations.cancel({ kind: "agent", agentId, idempotencyKey })).toMatchObject({
|
||||
status: "admitted",
|
||||
creation: { phase: "completed" },
|
||||
});
|
||||
expect(prompts).toBe(1);
|
||||
const observation = paseo.creations.observeAgent(idempotencyKey);
|
||||
const observation = paseo.creations.observe({ kind: "agent", idempotencyKey });
|
||||
expect((await observation.ready).snapshot).toMatchObject({ phase: "completed", agentId });
|
||||
await observation.release();
|
||||
});
|
||||
|
||||
test("parent archive cancels an unadmitted durable creation after origin restart without a stray child", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paseo-unadmitted-child-"));
|
||||
cleanup.push(() => rm(root, { recursive: true, force: true }));
|
||||
const a = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
|
||||
cleanup.push(() => a.close());
|
||||
const b = await daemon();
|
||||
const origin = await api(a);
|
||||
const destination = await api(b);
|
||||
const saved = await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||
await online(origin.paseo);
|
||||
const parent = await origin.paseo.agents.create({
|
||||
cwd: a.staticDir,
|
||||
config: { provider: "codex/test" },
|
||||
});
|
||||
const workspace = await destination.paseo.workspaces.create({
|
||||
source: { kind: "directory", path: b.staticDir },
|
||||
});
|
||||
await origin.paseo.hosts.update(saved.registrationId, { enabled: false });
|
||||
await expect(
|
||||
parent.subagents.create({
|
||||
serverId: b.daemon.getServerId(),
|
||||
workspaceId: workspace.id,
|
||||
test.each(["agent", "workspace"] as const)(
|
||||
"parent archive cancels an unadmitted %s creation after origin restart without stray resources",
|
||||
async (kind) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paseo-unadmitted-child-"));
|
||||
cleanup.push(() => rm(root, { recursive: true, force: true }));
|
||||
const a = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
|
||||
cleanup.push(() => a.close());
|
||||
const b = await daemon();
|
||||
const origin = await api(a);
|
||||
const destination = await api(b);
|
||||
const saved = await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||
await online(origin.paseo);
|
||||
const parent = await origin.paseo.agents.create({
|
||||
cwd: a.staticDir,
|
||||
config: { provider: "codex/test" },
|
||||
idempotencyKey: "unadmitted",
|
||||
prompt: "Must never be replayed",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "host_disabled" });
|
||||
const row = (await parent.subagents.list()).rows[0]!;
|
||||
expect(row.phase).toBe("creating");
|
||||
expect(await destination.paseo.agents.ref(row.child.agentId).refresh()).toBeNull();
|
||||
await parent.archive();
|
||||
expect((await parent.subagents.list()).rows[0]?.phase).toBe("detach_pending");
|
||||
await a.close();
|
||||
const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
|
||||
cleanup.push(() => restarted.close());
|
||||
const next = await api(restarted);
|
||||
const restored = next.paseo.agents.ref(parent.id);
|
||||
await next.paseo.hosts.update(saved.registrationId, { enabled: true });
|
||||
await online(next.paseo);
|
||||
await expect.poll(async () => (await restored.subagents.list()).rows[0]?.phase).toBe("canceled");
|
||||
expect(await destination.paseo.agents.ref(row.child.agentId).refresh()).toBeNull();
|
||||
await next.paseo.hosts.remove(saved.registrationId);
|
||||
});
|
||||
});
|
||||
const workspace = await destination.paseo.workspaces.create({
|
||||
source: { kind: "directory", path: b.staticDir },
|
||||
});
|
||||
await origin.paseo.hosts.update(saved.registrationId, { enabled: false });
|
||||
await expect(
|
||||
parent.subagents.create({
|
||||
serverId: b.daemon.getServerId(),
|
||||
...(kind === "workspace"
|
||||
? {
|
||||
workspace: { source: { kind: "directory" as const, path: b.staticDir } },
|
||||
cwd: b.staticDir,
|
||||
}
|
||||
: { workspaceId: workspace.id }),
|
||||
config: { provider: "codex/test" },
|
||||
idempotencyKey: "unadmitted",
|
||||
prompt: "Must never be replayed",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "host_disabled" });
|
||||
const row = (await parent.subagents.list()).rows[0]!;
|
||||
expect(row.phase).toBe("creating");
|
||||
expect(await destination.paseo.agents.ref(row.child.agentId).refresh()).toBeNull();
|
||||
await parent.archive();
|
||||
expect((await parent.subagents.list()).rows[0]?.phase).toBe("detach_pending");
|
||||
await a.close();
|
||||
const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
|
||||
cleanup.push(() => restarted.close());
|
||||
const next = await api(restarted);
|
||||
const restored = next.paseo.agents.ref(parent.id);
|
||||
await next.paseo.hosts.update(saved.registrationId, { enabled: true });
|
||||
await online(next.paseo);
|
||||
await expect
|
||||
.poll(async () => (await restored.subagents.list()).rows[0]?.phase)
|
||||
.toBe("canceled");
|
||||
expect(await destination.paseo.agents.ref(row.child.agentId).refresh()).toBeNull();
|
||||
if (kind === "workspace") {
|
||||
const delayed = await destination.client.createWorkspace({
|
||||
idempotencyKey: `subagent:${row.id}`,
|
||||
workspaceId: row.workspace!.workspaceId,
|
||||
source: { kind: "directory", path: b.staticDir },
|
||||
agent: {
|
||||
agentId: row.child.agentId,
|
||||
config: { provider: "codex", cwd: b.staticDir },
|
||||
initialPrompt: "Must never be replayed",
|
||||
},
|
||||
});
|
||||
expect(delayed).toMatchObject({ workspace: null, errorCode: "creation_canceled" });
|
||||
expect(
|
||||
(await destination.paseo.workspaces.list()).entries.some(
|
||||
(entry) => entry.id === row.workspace!.workspaceId,
|
||||
),
|
||||
).toBe(false);
|
||||
}
|
||||
await next.paseo.hosts.remove(saved.registrationId);
|
||||
},
|
||||
);
|
||||
|
||||
test("parent lifecycle recovers an admitted creation that becomes ready after origin restart", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paseo-admitted-child-"));
|
||||
@@ -2525,3 +2562,666 @@ test("failed admitted creation exposes its failure through lifecycle recovery an
|
||||
expect((await parent.subagents.list()).rows).toEqual([]);
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
|
||||
test("shared control and automation capabilities follow the same qualified workflow on every destination", async () => {
|
||||
const a = await daemon();
|
||||
const b = await daemon();
|
||||
const origin = await api(a);
|
||||
await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||
await online(origin.paseo);
|
||||
const runtime = origin.paseo.hosts.runtime();
|
||||
cleanup.push(() => runtime.dispose());
|
||||
const agentId = crypto.randomUUID();
|
||||
for (const destination of [a, b]) {
|
||||
const target = await runtime.client(destination.daemon.getServerId());
|
||||
cleanup.push(() => target.dispose());
|
||||
const agent = await target.agents.create({
|
||||
agentId,
|
||||
cwd: destination.staticDir,
|
||||
config: { provider: "codex/test", modeId: "default" },
|
||||
});
|
||||
await agent.update({ name: `Target ${agent.serverId}`, labels: { test: "qualified" } });
|
||||
await agent.setMode("default");
|
||||
expect((await agent.refresh())?.agent).toMatchObject({
|
||||
title: `Target ${agent.serverId}`,
|
||||
labels: { test: "qualified" },
|
||||
currentModeId: "default",
|
||||
});
|
||||
await agent.send('printf "ok" > sdk-control.txt', { messageId: "control-permission" });
|
||||
await expect
|
||||
.poll(async () => (await agent.runs.get("control-permission"))?.status)
|
||||
.toBe("permission");
|
||||
expect(await agent.cancel()).toEqual({ cancelled: true });
|
||||
expect(await agent.cancel()).toEqual({ cancelled: false });
|
||||
await expect
|
||||
.poll(async () => (await agent.runs.get("control-permission"))?.status)
|
||||
.toBe("canceled");
|
||||
const workspace = target.workspaces.ref((await agent.refresh())!.agent.workspaceId!);
|
||||
expect((await workspace.scripts.list()).scripts).toEqual([]);
|
||||
const created = await target.schedules.create({
|
||||
prompt: "Automation belongs to this destination",
|
||||
cadence: { type: "cron", expression: "0 0 * * *" },
|
||||
target: { type: "agent", agentId },
|
||||
runOnCreate: false,
|
||||
});
|
||||
expect(created.error).toBeNull();
|
||||
const id = created.schedule!.id;
|
||||
expect((await target.schedules.inspect({ id })).schedule?.target).toEqual({
|
||||
type: "agent",
|
||||
agentId,
|
||||
});
|
||||
expect((await target.schedules.pause({ id })).schedule?.status).toBe("paused");
|
||||
expect((await target.schedules.resume({ id })).schedule?.status).toBe("active");
|
||||
await target.schedules.delete({ id });
|
||||
await agent.close();
|
||||
expect(await agent.refresh()).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("disposing a same-server scope fences captured workspace scripts while sibling and origin remain usable", async () => {
|
||||
const instance = await daemon();
|
||||
await writeFile(
|
||||
join(instance.staticDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
scripts: {
|
||||
fixture: {
|
||||
type: "service",
|
||||
command: `node -e "require('node:fs').writeFileSync('scope-effect.txt','started')"`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const origin = await api(instance);
|
||||
const workspace = await origin.paseo.workspaces.create({
|
||||
source: { kind: "directory", path: instance.staticDir },
|
||||
});
|
||||
const runtime = origin.paseo.hosts.runtime();
|
||||
cleanup.push(() => runtime.dispose());
|
||||
const scope = await runtime.client(instance.daemon.getServerId());
|
||||
const sibling = await runtime.client(instance.daemon.getServerId());
|
||||
cleanup.push(() => sibling.dispose());
|
||||
const captured = scope.workspaces.ref(workspace.id);
|
||||
expect((await captured.scripts.list()).scripts.map((script) => script.scriptName)).toContain(
|
||||
"fixture",
|
||||
);
|
||||
await scope.dispose();
|
||||
await expect(Promise.resolve().then(() => captured.scripts.start("fixture"))).rejects.toThrow(
|
||||
"disposed",
|
||||
);
|
||||
await expect(Promise.resolve().then(() => captured.scripts.stop("fixture"))).rejects.toThrow(
|
||||
"disposed",
|
||||
);
|
||||
await expect(Promise.resolve().then(() => captured.scripts.list())).rejects.toThrow("disposed");
|
||||
expect(
|
||||
(await sibling.workspaces.ref(workspace.id).scripts.list()).scripts.map(
|
||||
(script) => script.scriptName,
|
||||
),
|
||||
).toContain("fixture");
|
||||
expect(
|
||||
(await origin.paseo.workspaces.ref(workspace.id).scripts.list()).scripts.map(
|
||||
(script) => script.scriptName,
|
||||
),
|
||||
).toContain("fixture");
|
||||
await expect(stat(join(instance.staticDir, "scope-effect.txt"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
|
||||
test("ordinary sends suppress archived recipients on every destination without starting tracked work", async () => {
|
||||
const a = await daemon();
|
||||
const b = await daemon();
|
||||
const origin = await api(a);
|
||||
await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||
await online(origin.paseo);
|
||||
const runtime = origin.paseo.hosts.runtime();
|
||||
cleanup.push(() => runtime.dispose());
|
||||
for (const destination of [a, b]) {
|
||||
const target = await runtime.client(destination.daemon.getServerId());
|
||||
cleanup.push(() => target.dispose());
|
||||
const agent = await target.agents.create({
|
||||
cwd: destination.staticDir,
|
||||
config: { provider: "codex/test" },
|
||||
});
|
||||
await agent.archive();
|
||||
const dispatch = await agent.send("Do not wake this archived recipient", {
|
||||
messageId: "suppressed-delivery",
|
||||
unarchive: false,
|
||||
activeTurnBehavior: "steer",
|
||||
});
|
||||
expect((await agent.refresh())?.agent.archivedAt).toBeTruthy();
|
||||
expect(dispatch).toMatchObject({
|
||||
submissionId: "suppressed-delivery",
|
||||
disposition: "suppressed",
|
||||
tracking: "unavailable",
|
||||
});
|
||||
expect(await agent.runs.get("suppressed-delivery")).toBeNull();
|
||||
expect(
|
||||
await agent.send("Do not wake this archived recipient", {
|
||||
messageId: "suppressed-delivery",
|
||||
unarchive: false,
|
||||
activeTurnBehavior: "steer",
|
||||
}),
|
||||
).toEqual(dispatch);
|
||||
}
|
||||
});
|
||||
|
||||
test("accepted steering and out-of-band sends expose actual tracking on every destination", async () => {
|
||||
let release!: () => void;
|
||||
const terminal = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
cleanup.push(async () => release());
|
||||
let starts = 0;
|
||||
let commands = 0;
|
||||
const clients = () =>
|
||||
createTestAgentClients({
|
||||
onStartTurn: () => {
|
||||
starts++;
|
||||
},
|
||||
beforeTurnComplete: () => terminal,
|
||||
steerActiveTurn: async () => ({ status: "accepted" }),
|
||||
tryHandleOutOfBand: (prompt) =>
|
||||
prompt === "/side-effect"
|
||||
? {
|
||||
run: async () => {
|
||||
commands++;
|
||||
},
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const a = await createTestPaseoDaemon({ agentClients: clients() });
|
||||
const b = await createTestPaseoDaemon({ agentClients: clients() });
|
||||
// Shutdown must not wait on a deliberately held provider completion.
|
||||
cleanup.push(() => a.close());
|
||||
cleanup.push(() => b.close());
|
||||
const origin = await api(a);
|
||||
await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||
await online(origin.paseo);
|
||||
const runtime = origin.paseo.hosts.runtime();
|
||||
cleanup.push(() => runtime.dispose());
|
||||
const agents = [];
|
||||
try {
|
||||
for (const destination of [a, b]) {
|
||||
const target = await runtime.client(destination.daemon.getServerId());
|
||||
cleanup.push(() => target.dispose());
|
||||
const agent = await target.agents.create({
|
||||
cwd: destination.staticDir,
|
||||
config: { provider: "codex/test" },
|
||||
});
|
||||
agents.push(agent);
|
||||
const checkpoints: string[] = [];
|
||||
const observing = agent.runs.observe("accepted-steer");
|
||||
cleanup.push(() => observing.release());
|
||||
observing.subscribe({
|
||||
snapshot: ({ run }) => {
|
||||
if (run) checkpoints.push(run.status);
|
||||
},
|
||||
update: () => {},
|
||||
});
|
||||
expect((await observing.ready).run).toBeNull();
|
||||
expect(await agent.send("Reply with initial output", { messageId: "initial" })).toMatchObject(
|
||||
{ disposition: "turn_started", tracking: "turn" },
|
||||
);
|
||||
expect(
|
||||
await agent.send("Continue the existing turn", {
|
||||
messageId: "accepted-steer",
|
||||
activeTurnBehavior: "steer",
|
||||
}),
|
||||
).toMatchObject({ disposition: "steered", tracking: "turn" });
|
||||
await expect.poll(() => checkpoints).toContain("running");
|
||||
expect((await agent.runs.get("accepted-steer"))?.turnId).toBe(
|
||||
(await agent.runs.get("initial"))?.turnId,
|
||||
);
|
||||
const command = await agent.send("/side-effect", { messageId: "accepted-command" });
|
||||
expect(command).toMatchObject({ disposition: "out_of_band", tracking: "unavailable" });
|
||||
expect(await agent.runs.get("accepted-command")).toBeNull();
|
||||
expect(await agent.send("/side-effect", { messageId: "accepted-command" })).toEqual(command);
|
||||
}
|
||||
expect(starts).toBe(2);
|
||||
expect(commands).toBe(2);
|
||||
release();
|
||||
for (const agent of agents) {
|
||||
await expect
|
||||
.poll(async () => (await agent.runs.get("accepted-steer"))?.status)
|
||||
.toBe("completed");
|
||||
expect((await agent.runs.get("initial"))?.status).toBe("completed");
|
||||
const completed = agent.runs.observe("accepted-steer");
|
||||
expect((await completed.ready).run?.status).toBe("completed");
|
||||
await completed.release();
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
test("pending message receipt across restart exposes unknown delivery and conflict without resending", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paseo-message-receipt-restart-"));
|
||||
cleanup.push(() => rm(root, { recursive: true, force: true }));
|
||||
let directory = "";
|
||||
let starts = 0;
|
||||
let injectFailure = true;
|
||||
const clients = () =>
|
||||
createTestAgentClients({
|
||||
onStartTurn: () => {
|
||||
starts++;
|
||||
if (!injectFailure) return;
|
||||
injectFailure = false;
|
||||
renameSync(directory, `${directory}.saved`);
|
||||
writeFileSync(directory, "Receipt storage temporarily unavailable");
|
||||
},
|
||||
});
|
||||
const first = await createTestPaseoDaemon({
|
||||
paseoHomeRoot: root,
|
||||
cleanup: false,
|
||||
agentClients: clients(),
|
||||
});
|
||||
cleanup.push(() => first.close());
|
||||
directory = join(first.paseoHome, "agent-requests");
|
||||
const origin = await api(first);
|
||||
const agent = await origin.paseo.agents.create({
|
||||
cwd: first.staticDir,
|
||||
config: { provider: "codex/test" },
|
||||
});
|
||||
try {
|
||||
await expect(agent.send("Reply once", { messageId: "uncertain" })).rejects.toMatchObject({
|
||||
code: "agent_request_outcome_unknown",
|
||||
outcome: "unknown",
|
||||
});
|
||||
} finally {
|
||||
rmSync(directory, { force: true });
|
||||
renameSync(`${directory}.saved`, directory);
|
||||
}
|
||||
await first.close();
|
||||
const restarted = await createTestPaseoDaemon({
|
||||
paseoHomeRoot: root,
|
||||
cleanup: false,
|
||||
agentClients: clients(),
|
||||
});
|
||||
cleanup.push(() => restarted.close());
|
||||
const next = await api(restarted);
|
||||
const restored = next.paseo.agents.ref(agent.id);
|
||||
await expect(restored.send("Reply once", { messageId: "uncertain" })).rejects.toMatchObject({
|
||||
code: "agent_request_outcome_unknown",
|
||||
outcome: "unknown",
|
||||
});
|
||||
const conflict = await restored
|
||||
.send("Different request", { messageId: "uncertain" })
|
||||
.catch((error: unknown) => error);
|
||||
expect(conflict).toMatchObject({ code: "agent_request_key_conflict" });
|
||||
expect(conflict).not.toHaveProperty("outcome", "unknown");
|
||||
expect(starts).toBe(1);
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"archiving a parent completes its schedule and children despite failing observer: %s",
|
||||
async (failing) => {
|
||||
const instance = await daemon();
|
||||
const { paseo } = await api(instance);
|
||||
const parent = await paseo.agents.create({
|
||||
cwd: instance.staticDir,
|
||||
config: { provider: "codex/test" },
|
||||
});
|
||||
const child = await parent.subagents.create({ config: { provider: "codex/test" } });
|
||||
const schedule = await paseo.schedules.create({
|
||||
prompt: "Scheduled parent work",
|
||||
cadence: { type: "cron", expression: "0 0 * * *" },
|
||||
target: { type: "agent", agentId: parent.id },
|
||||
runOnCreate: false,
|
||||
});
|
||||
expect(schedule.error).toBeNull();
|
||||
const stop = instance.daemon.agentManager.onAgentArchived(() => {
|
||||
if (failing) throw new Error("Independent listener failed");
|
||||
});
|
||||
cleanup.push(async () => stop());
|
||||
await parent.archive();
|
||||
expect((await paseo.schedules.inspect({ id: schedule.schedule!.id })).schedule?.status).toBe(
|
||||
"completed",
|
||||
);
|
||||
expect((await parent.subagents.list()).rows[0]).toMatchObject({
|
||||
child: child.address,
|
||||
phase: "archived",
|
||||
});
|
||||
expect((await child.refresh())?.agent.archivedAt).toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
test("parented modern creation retains one reserved identity and exact child directory across retries", async () => {
|
||||
let prompts = 0;
|
||||
const instance = await createTestPaseoDaemon({
|
||||
agentClients: createTestAgentClients({
|
||||
onStartTurn: () => {
|
||||
prompts++;
|
||||
},
|
||||
}),
|
||||
});
|
||||
cleanup.push(() => instance.close());
|
||||
const origin = await api(instance);
|
||||
const parent = await origin.paseo.agents.create({
|
||||
cwd: instance.staticDir,
|
||||
config: { provider: "codex/test" },
|
||||
});
|
||||
const directory = join(instance.staticDir, "nested-child");
|
||||
await mkdir(directory);
|
||||
const request = {
|
||||
agentId: crypto.randomUUID(),
|
||||
idempotencyKey: "parented-original-key",
|
||||
callerAgentId: parent.id,
|
||||
config: { provider: "codex", model: "test", cwd: directory },
|
||||
initialPrompt: "Reply once for this child",
|
||||
clientMessageId: "original-initial-prompt",
|
||||
};
|
||||
const child = await origin.client.createAgent(request);
|
||||
expect(child).toMatchObject({ id: request.agentId, cwd: directory, parent: parent.address });
|
||||
expect((await parent.subagents.list()).rows).toHaveLength(1);
|
||||
expect((await origin.client.createAgent(request)).id).toBe(child.id);
|
||||
expect(prompts).toBe(1);
|
||||
});
|
||||
|
||||
test("parented workspace creation retains its original receipt and both resources across retry", async () => {
|
||||
let prompts = 0;
|
||||
const instance = await createTestPaseoDaemon({
|
||||
agentClients: createTestAgentClients({
|
||||
onStartTurn: () => {
|
||||
prompts++;
|
||||
},
|
||||
}),
|
||||
});
|
||||
cleanup.push(() => instance.close());
|
||||
const origin = await api(instance);
|
||||
const parent = await origin.paseo.agents.create({
|
||||
cwd: instance.staticDir,
|
||||
config: { provider: "codex/test" },
|
||||
});
|
||||
const directory = join(instance.staticDir, "workspace-child");
|
||||
await mkdir(directory);
|
||||
const workspaceId = "wks_0123456789abcdef";
|
||||
const agentId = crypto.randomUUID();
|
||||
const request = {
|
||||
idempotencyKey: "original-workspace-key",
|
||||
workspaceId,
|
||||
source: { kind: "directory" as const, path: instance.staticDir },
|
||||
agent: {
|
||||
agentId,
|
||||
callerAgentId: parent.id,
|
||||
config: { provider: "codex", model: "test", cwd: directory },
|
||||
initialPrompt: "Reply once from workspace child",
|
||||
clientMessageId: "workspace-initial",
|
||||
},
|
||||
};
|
||||
const created = await origin.client.createWorkspace(request);
|
||||
expect(created.error).toBeNull();
|
||||
expect(created.workspace?.id).toBe(workspaceId);
|
||||
const child = origin.paseo.agents.ref(agentId);
|
||||
expect((await child.refresh())?.agent).toMatchObject({
|
||||
workspaceId,
|
||||
cwd: directory,
|
||||
parent: parent.address,
|
||||
});
|
||||
expect((await parent.subagents.list()).rows).toHaveLength(1);
|
||||
const repeated = await origin.client.createWorkspace(request);
|
||||
expect(repeated.workspace?.id).toBe(workspaceId);
|
||||
expect(prompts).toBe(1);
|
||||
const receipt = origin.paseo.creations.observe({
|
||||
kind: "workspace",
|
||||
idempotencyKey: request.idempotencyKey,
|
||||
});
|
||||
expect((await receipt.ready).snapshot).toMatchObject({
|
||||
kind: "workspace",
|
||||
agentId,
|
||||
workspaceId,
|
||||
phase: "completed",
|
||||
});
|
||||
await receipt.release();
|
||||
});
|
||||
|
||||
test("workspace creation cancellation fences both resources across restart without crossing key namespaces", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "workspace-creation-cancel-"));
|
||||
cleanup.push(() => rm(root, { recursive: true, force: true }));
|
||||
let starts = 0;
|
||||
const clients = () =>
|
||||
createTestAgentClients({
|
||||
onStartTurn: () => {
|
||||
starts++;
|
||||
},
|
||||
});
|
||||
const first = await createTestPaseoDaemon({
|
||||
paseoHomeRoot: root,
|
||||
cleanup: false,
|
||||
agentClients: clients(),
|
||||
});
|
||||
cleanup.push(() => first.close());
|
||||
const origin = await api(first);
|
||||
const idempotencyKey = "same-textual-key";
|
||||
const workspaceId = "wks_1234567890abcdef";
|
||||
const agentId = crypto.randomUUID();
|
||||
const reservation = { kind: "workspace" as const, workspaceId, agentId, idempotencyKey };
|
||||
expect(await origin.paseo.creations.cancel(reservation)).toMatchObject({
|
||||
status: "canceled",
|
||||
reservation: {
|
||||
kind: "workspace",
|
||||
reservedAgent: { serverId: first.daemon.getServerId(), agentId },
|
||||
reservedWorkspace: { serverId: first.daemon.getServerId(), workspaceId },
|
||||
},
|
||||
});
|
||||
for (const mismatch of [
|
||||
{ ...reservation, agentId: crypto.randomUUID() },
|
||||
{ ...reservation, workspaceId: "wks_1234567890abcdee" },
|
||||
])
|
||||
await expect(origin.paseo.creations.cancel(mismatch)).rejects.toMatchObject({
|
||||
code: "agent_request_key_conflict",
|
||||
});
|
||||
const independent = await origin.paseo.agents.create({
|
||||
idempotencyKey,
|
||||
cwd: first.staticDir,
|
||||
config: { provider: "codex/test" },
|
||||
});
|
||||
expect(independent.id).not.toBe(agentId);
|
||||
const request = {
|
||||
idempotencyKey,
|
||||
workspaceId,
|
||||
source: { kind: "directory" as const, path: first.staticDir },
|
||||
agent: {
|
||||
agentId,
|
||||
config: { provider: "codex", model: "test", cwd: first.staticDir },
|
||||
initialPrompt: "Must never run",
|
||||
},
|
||||
};
|
||||
expect(await origin.client.createWorkspace(request)).toMatchObject({
|
||||
errorCode: "creation_canceled",
|
||||
workspace: null,
|
||||
});
|
||||
await first.close();
|
||||
const restarted = await createTestPaseoDaemon({
|
||||
paseoHomeRoot: root,
|
||||
cleanup: false,
|
||||
agentClients: clients(),
|
||||
});
|
||||
cleanup.push(() => restarted.close());
|
||||
const next = await api(restarted);
|
||||
expect(await next.client.createWorkspace(request)).toMatchObject({
|
||||
errorCode: "creation_canceled",
|
||||
workspace: null,
|
||||
});
|
||||
expect(
|
||||
(await next.paseo.workspaces.list()).entries.some((workspace) => workspace.id === workspaceId),
|
||||
).toBe(false);
|
||||
expect(await next.paseo.agents.ref(agentId).refresh()).toBeNull();
|
||||
expect(starts).toBe(0);
|
||||
});
|
||||
|
||||
test("admitted workspace cancellation returns its existing receipt and leaves its runner intact", async () => {
|
||||
let release!: () => void;
|
||||
let reached!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
reached = resolve;
|
||||
});
|
||||
cleanup.push(async () => release());
|
||||
let prompts = 0;
|
||||
const instance = await createTestPaseoDaemon({
|
||||
agentClients: createTestAgentClients({
|
||||
beforeCreateSession: async (config) => {
|
||||
if (!config.internal) {
|
||||
reached();
|
||||
await gate;
|
||||
}
|
||||
},
|
||||
onStartTurn: (_prompt, config) => {
|
||||
if (!config.internal) prompts++;
|
||||
},
|
||||
}),
|
||||
});
|
||||
cleanup.push(() => instance.close());
|
||||
const origin = await api(instance);
|
||||
const agentId = crypto.randomUUID();
|
||||
const workspaceId = "wks_abcdef0123456789";
|
||||
const idempotencyKey = "workspace-won-admission";
|
||||
const request = {
|
||||
idempotencyKey,
|
||||
workspaceId,
|
||||
source: { kind: "directory" as const, path: instance.staticDir },
|
||||
agent: {
|
||||
agentId,
|
||||
config: { provider: "codex", model: "test", cwd: instance.staticDir },
|
||||
initialPrompt: "Run once after admission",
|
||||
},
|
||||
};
|
||||
const creating = origin.client.createWorkspace(request);
|
||||
void creating.catch(() => {});
|
||||
try {
|
||||
await ready;
|
||||
expect(
|
||||
await origin.paseo.creations.cancel({
|
||||
kind: "workspace",
|
||||
idempotencyKey,
|
||||
workspaceId,
|
||||
agentId,
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: "admitted",
|
||||
creation: { kind: "workspace", workspaceId, agentId, phase: "workspace_ready" },
|
||||
});
|
||||
await expect(
|
||||
origin.paseo.creations.cancel({
|
||||
kind: "workspace",
|
||||
idempotencyKey,
|
||||
workspaceId: "wks_abcdef0123456788",
|
||||
agentId,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "agent_request_key_conflict" });
|
||||
expect(
|
||||
await origin.paseo.creations.cancel({ kind: "agent", idempotencyKey, agentId }),
|
||||
).toMatchObject({ status: "canceled", reservation: { kind: "agent" } });
|
||||
release();
|
||||
expect((await creating).workspace?.id).toBe(workspaceId);
|
||||
expect(prompts).toBe(1);
|
||||
expect(
|
||||
await origin.paseo.creations.cancel({
|
||||
kind: "workspace",
|
||||
idempotencyKey,
|
||||
workspaceId,
|
||||
agentId,
|
||||
}),
|
||||
).toMatchObject({ status: "admitted", creation: { phase: "completed" } });
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"legacy parented creation preserves exact directory and prompt with worktree: %s",
|
||||
async (worktree) => {
|
||||
let prompts = 0;
|
||||
const instance = await createTestPaseoDaemon({
|
||||
agentClients: createTestAgentClients({
|
||||
onStartTurn: () => {
|
||||
prompts++;
|
||||
},
|
||||
}),
|
||||
});
|
||||
cleanup.push(() => instance.close());
|
||||
const origin = await api(instance);
|
||||
const directory = join(instance.staticDir, "legacy-nested");
|
||||
await mkdir(directory);
|
||||
if (worktree) {
|
||||
const git = (args: string[]) => promisify(execFile)("git", args, { cwd: instance.staticDir });
|
||||
await git(["init", "-b", "main"]);
|
||||
await git(["config", "user.name", "Paseo Test"]);
|
||||
await git(["config", "user.email", "test@example.com"]);
|
||||
await writeFile(join(directory, "README.md"), "Track exact launch directory");
|
||||
await git(["add", "."]);
|
||||
await git(["commit", "-m", "Initial"]);
|
||||
}
|
||||
const parent = await origin.paseo.agents.create({
|
||||
cwd: instance.staticDir,
|
||||
config: { provider: "codex/test" },
|
||||
});
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${instance.port}/ws`);
|
||||
cleanup.push(async () => socket.terminate());
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
socket.on("message", (data) => {
|
||||
const parsed = WSOutboundMessageSchema.parse(JSON.parse(String(data)));
|
||||
if (parsed.type === "session") messages.push(parsed.message);
|
||||
});
|
||||
await once(socket, "open");
|
||||
const hello = once(socket, "message");
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
clientId: "legacy-parented",
|
||||
clientType: "cli",
|
||||
protocolVersion: 1,
|
||||
}),
|
||||
);
|
||||
await hello;
|
||||
const request = {
|
||||
type: "create_agent_request",
|
||||
...(worktree ? { worktree: { mode: "branch-off", newBranch: "legacy-child" } } : {}),
|
||||
callerAgentId: parent.id,
|
||||
config: { provider: "codex", model: "test", cwd: directory },
|
||||
labels: {},
|
||||
attachments: [],
|
||||
initialPrompt: "Run legacy child once",
|
||||
clientMessageId: "legacy-initial",
|
||||
};
|
||||
for (const attempt of [1, 2]) {
|
||||
const requestId = "legacy-original";
|
||||
socket.send(JSON.stringify({ type: "session", message: { ...request, requestId } }));
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
messages.filter(
|
||||
(message) => message.type === "status" && message.payload.requestId === requestId,
|
||||
).length,
|
||||
)
|
||||
.toBe(attempt);
|
||||
const response = messages.find(
|
||||
(message) => message.type === "status" && message.payload.requestId === requestId,
|
||||
);
|
||||
expect(
|
||||
response?.type === "status" && "error" in response.payload
|
||||
? response.payload.error
|
||||
: undefined,
|
||||
).toBeUndefined();
|
||||
expect(response).toMatchObject({
|
||||
type: "status",
|
||||
payload: { status: "agent_created", agent: { parent: parent.address } },
|
||||
});
|
||||
}
|
||||
const rows = (await parent.subagents.list()).rows;
|
||||
expect(rows).toHaveLength(1);
|
||||
const child = origin.paseo.agents.ref(rows[0]!.child.agentId);
|
||||
const snapshot = (await child.refresh())!.agent;
|
||||
if (worktree) {
|
||||
expect(snapshot.cwd).not.toBe(directory);
|
||||
const { stdout } = await promisify(execFile)("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd: snapshot.cwd,
|
||||
});
|
||||
expect(snapshot.cwd).toBe(join(stdout.trim(), "legacy-nested"));
|
||||
expect(snapshot.workspaceId).not.toBe((await parent.refresh())!.agent.workspaceId);
|
||||
} else expect(snapshot.cwd).toBe(directory);
|
||||
expect(prompts).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -343,7 +343,9 @@ test("cancellation preserves an admitted outcome-unknown receipt after owner res
|
||||
void f.service.create(input);
|
||||
await entered.promise;
|
||||
const restarted = new CreationService(f.directory, silentLogger);
|
||||
expect(await restarted.cancelAgent(input.key, agent.id)).toMatchObject({
|
||||
expect(
|
||||
await restarted.cancel({ kind: "agent", idempotencyKey: input.key, agentId: agent.id }),
|
||||
).toMatchObject({
|
||||
status: "admitted",
|
||||
creation: { phase: "accepted", agentId: agent.id },
|
||||
});
|
||||
@@ -353,7 +355,9 @@ test("cancellation preserves an admitted outcome-unknown receipt after owner res
|
||||
failedStage: "agent",
|
||||
outcomeUnknown: true,
|
||||
});
|
||||
expect(await restarted.cancelAgent(input.key, agent.id)).toMatchObject({
|
||||
expect(
|
||||
await restarted.cancel({ kind: "agent", idempotencyKey: input.key, agentId: agent.id }),
|
||||
).toMatchObject({
|
||||
status: "admitted",
|
||||
creation: { phase: "failed", outcomeUnknown: true },
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CreationSnapshotSchema,
|
||||
CreationCancelInputSchema,
|
||||
type CreationCancelInput,
|
||||
type AgentSnapshotPayload,
|
||||
type CreationSnapshot,
|
||||
type WorkspaceDescriptorPayload,
|
||||
@@ -128,42 +130,59 @@ export class CreationService {
|
||||
input: CreationInput,
|
||||
identity: string,
|
||||
): Promise<CreationSnapshot | null> {
|
||||
if (input.kind !== "agent") return null;
|
||||
const agentId = await this.readCancellation(identity);
|
||||
if (!agentId) return null;
|
||||
if (input.agentId && input.agentId !== agentId) throw conflict();
|
||||
return cancellationSnapshot(input.key, agentId);
|
||||
const canceled = await this.readCancellation(identity);
|
||||
if (!canceled) return null;
|
||||
if (
|
||||
(input.agentId && input.agentId !== canceled.agentId) ||
|
||||
(canceled.kind === "workspace" &&
|
||||
input.workspaceId &&
|
||||
input.workspaceId !== canceled.workspaceId)
|
||||
)
|
||||
throw conflict();
|
||||
return cancellationSnapshot(canceled);
|
||||
}
|
||||
|
||||
/** Fences a creation identity only before admission. Running receipts remain owned by their runner. */
|
||||
cancelAgent(
|
||||
key: string,
|
||||
agentId: string,
|
||||
cancel(
|
||||
input: CreationCancelInput,
|
||||
): Promise<{ status: "canceled" } | { status: "admitted"; creation: CreationSnapshot }> {
|
||||
const identity = identityFor("agent", key);
|
||||
const { idempotencyKey: key, agentId } = input;
|
||||
const identity = identityFor(input.kind, key);
|
||||
const operation = this.admission.then(async () => {
|
||||
await mkdir(this.directory, { recursive: true });
|
||||
const record = await this.read(identity);
|
||||
if (record) {
|
||||
if (record.snapshot.agentId !== agentId) throw conflict();
|
||||
if (
|
||||
record.snapshot.agentId !== agentId ||
|
||||
(input.kind === "workspace" && record.snapshot.workspaceId !== input.workspaceId)
|
||||
)
|
||||
throw conflict();
|
||||
return { status: "admitted" as const, creation: record.snapshot };
|
||||
}
|
||||
// COMPAT(agentRequestReceipts): added in v0.8.0, remove after 2027-03-11 once old creation receipts can expire.
|
||||
// A legacy receipt is evidence of admission, never permission to cancel an uncertain runner.
|
||||
if (
|
||||
input.kind === "agent" &&
|
||||
this.legacyDirectory &&
|
||||
(await readOptional(join(this.legacyDirectory, `${digest(["create", key])}.json`))) !== null
|
||||
)
|
||||
throw Object.assign(new Error("Cannot cancel an existing legacy creation receipt"), {
|
||||
code: "creation_already_admitted",
|
||||
});
|
||||
const canceledAgentId = await this.readCancellation(identity);
|
||||
if (canceledAgentId && canceledAgentId !== agentId) throw conflict();
|
||||
const canceled = await this.readCancellation(identity);
|
||||
if (
|
||||
canceled &&
|
||||
(canceled.agentId !== agentId ||
|
||||
(input.kind === "workspace" &&
|
||||
canceled.kind === "workspace" &&
|
||||
canceled.workspaceId !== input.workspaceId))
|
||||
)
|
||||
throw conflict();
|
||||
await writeFileAtomic(
|
||||
join(this.directory, `${identity}.canceled.json`),
|
||||
JSON.stringify({ agentId }),
|
||||
JSON.stringify(input),
|
||||
);
|
||||
const snapshot = cancellationSnapshot(key, agentId);
|
||||
const snapshot = cancellationSnapshot(input);
|
||||
for (const observer of this.observers.get(identity) ?? []) this.notify(observer, snapshot);
|
||||
return { status: "canceled" as const };
|
||||
});
|
||||
@@ -173,11 +192,9 @@ export class CreationService {
|
||||
);
|
||||
return operation;
|
||||
}
|
||||
private async readCancellation(identity: string): Promise<string | null> {
|
||||
private async readCancellation(identity: string): Promise<CreationCancelInput | null> {
|
||||
const text = await readOptional(join(this.directory, `${identity}.canceled.json`));
|
||||
return text === null
|
||||
? null
|
||||
: z.object({ agentId: z.string().uuid() }).parse(JSON.parse(text)).agentId;
|
||||
return text === null ? null : CreationCancelInputSchema.parse(JSON.parse(text));
|
||||
}
|
||||
|
||||
async subscribe(
|
||||
@@ -199,10 +216,9 @@ export class CreationService {
|
||||
});
|
||||
}
|
||||
if (record?.snapshot.phase === "completed") await this.validateCompleted(record.snapshot);
|
||||
const canceledAgentId = kind === "agent" ? await this.readCancellation(identity) : null;
|
||||
const canceled = await this.readCancellation(identity);
|
||||
return {
|
||||
snapshot:
|
||||
record?.snapshot ?? (canceledAgentId ? cancellationSnapshot(key, canceledAgentId) : null),
|
||||
snapshot: record?.snapshot ?? (canceled ? cancellationSnapshot(canceled) : null),
|
||||
unsubscribe,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -444,16 +460,16 @@ function conflict(): Error {
|
||||
code: "agent_request_key_conflict",
|
||||
});
|
||||
}
|
||||
function cancellationSnapshot(key: string, agentId: string): CreationSnapshot {
|
||||
function cancellationSnapshot(input: CreationCancelInput): CreationSnapshot {
|
||||
return {
|
||||
kind: "agent",
|
||||
idempotencyKey: key,
|
||||
kind: input.kind,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
revision: 0,
|
||||
phase: "failed",
|
||||
workspaceId: null,
|
||||
agentId,
|
||||
workspaceId: input.kind === "workspace" ? input.workspaceId : null,
|
||||
agentId: input.agentId,
|
||||
error: "Creation was canceled before admission",
|
||||
errorCode: "creation_canceled",
|
||||
failedStage: "agent",
|
||||
failedStage: input.kind,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,3 +75,23 @@ test("failed local message preparation does not leave an ambiguous receipt", asy
|
||||
await requests.send(input);
|
||||
expect(sends).toBe(1);
|
||||
});
|
||||
|
||||
test("accepted untracked and suppressed dispatch evidence survives receipt reconstruction", async () => {
|
||||
const { requests, directory } = await fixture();
|
||||
for (const disposition of ["out_of_band", "suppressed"] as const) {
|
||||
let sends = 0;
|
||||
const dispatch = { disposition, tracking: "unavailable" as const };
|
||||
const input = {
|
||||
agentId: "agent",
|
||||
messageId: disposition,
|
||||
request: { disposition },
|
||||
send: async () => {
|
||||
sends++;
|
||||
return dispatch;
|
||||
},
|
||||
};
|
||||
expect(await requests.send(input)).toEqual(dispatch);
|
||||
expect(await new MessageReceipts(directory).send(input)).toEqual(dispatch);
|
||||
expect(sends).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentDispatchSchema, type AgentDispatch } from "@getpaseo/protocol/agent-dispatch";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -8,21 +9,22 @@ const ReceiptSchema = z.object({
|
||||
fingerprint: z.string(),
|
||||
state: z.enum(["pending", "completed"]),
|
||||
agentId: z.string(),
|
||||
dispatch: AgentDispatchSchema.optional(),
|
||||
});
|
||||
interface SendMessageInput {
|
||||
agentId: string;
|
||||
messageId: string;
|
||||
request: unknown;
|
||||
send: () => Promise<void>;
|
||||
send: () => Promise<AgentDispatch | void>;
|
||||
prepare?: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Owns message delivery receipts; creation is owned by CreationService. */
|
||||
export class MessageReceipts {
|
||||
private readonly pending = new Map<string, Promise<void>>();
|
||||
private readonly pending = new Map<string, Promise<AgentDispatch | void>>();
|
||||
constructor(private readonly directory: string) {}
|
||||
|
||||
send(input: SendMessageInput): Promise<void> {
|
||||
send(input: SendMessageInput): Promise<AgentDispatch | void> {
|
||||
// Preserve the existing on-disk identity and shape across daemon upgrades.
|
||||
const key = digest(["send", input.agentId, input.messageId]);
|
||||
const previous = this.pending.get(key);
|
||||
@@ -38,21 +40,42 @@ export class MessageReceipts {
|
||||
return result;
|
||||
}
|
||||
|
||||
private async sendOnce(key: string, input: SendMessageInput): Promise<void> {
|
||||
private async sendOnce(key: string, input: SendMessageInput): Promise<AgentDispatch | void> {
|
||||
const file = path.join(this.directory, `${key}.json`);
|
||||
const fingerprint = digest(input.request);
|
||||
const existing = await readReceipt(file);
|
||||
if (existing) {
|
||||
if (existing.fingerprint !== fingerprint) throw new Error("agent_request_key_conflict");
|
||||
if (existing.state === "completed") return;
|
||||
if (existing.fingerprint !== fingerprint)
|
||||
throw Object.assign(new Error("agent_request_key_conflict"), {
|
||||
code: "agent_request_key_conflict",
|
||||
});
|
||||
if (existing.state === "completed") return existing.dispatch;
|
||||
// A provider may have accepted the message before its receipt was committed.
|
||||
throw new Error("agent_request_outcome_unknown");
|
||||
throw Object.assign(new Error("agent_request_outcome_unknown"), {
|
||||
code: "agent_request_outcome_unknown",
|
||||
outcome: "unknown",
|
||||
});
|
||||
}
|
||||
await input.prepare?.();
|
||||
const receipt = { fingerprint, agentId: input.agentId };
|
||||
await writeJsonFileAtomic(file, { ...receipt, state: "pending" });
|
||||
await input.send();
|
||||
await writeJsonFileAtomic(file, { ...receipt, state: "completed" });
|
||||
const dispatch = await input.send();
|
||||
try {
|
||||
await writeJsonFileAtomic(file, {
|
||||
...receipt,
|
||||
state: "completed",
|
||||
...(dispatch ? { dispatch } : {}),
|
||||
});
|
||||
} catch (cause) {
|
||||
throw Object.assign(
|
||||
new Error("Message was dispatched but its receipt could not be committed", { cause }),
|
||||
{
|
||||
code: "agent_request_outcome_unknown",
|
||||
outcome: "unknown",
|
||||
},
|
||||
);
|
||||
}
|
||||
return dispatch;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { fromMessageSource } from "./agent/agent-messages/index.js";
|
||||
import type { AgentDispatch } from "@getpaseo/protocol/agent-dispatch";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import type { Subagents, SubagentAuthority } from "./subagents/index.js";
|
||||
import { AgentDirectory } from "./agent/directory.js";
|
||||
@@ -473,7 +475,7 @@ export interface SessionOptions {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
messageReceipts: Pick<MessageReceipts, "send">;
|
||||
creationService: Pick<CreationService, "create" | "subscribe" | "cancelAgent">;
|
||||
creationService: Pick<CreationService, "create" | "subscribe" | "cancel">;
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
directorySync?: DirectorySyncService;
|
||||
@@ -831,7 +833,7 @@ export class Session {
|
||||
private readonly workspaceScripts: WorkspaceScriptsService;
|
||||
private readonly messageReceipts: Pick<MessageReceipts, "send">;
|
||||
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
|
||||
private readonly creationService: Pick<CreationService, "create" | "subscribe" | "cancelAgent">;
|
||||
private readonly creationService: Pick<CreationService, "create" | "subscribe" | "cancel">;
|
||||
|
||||
constructor(options: SessionOptions) {
|
||||
const {
|
||||
@@ -2949,7 +2951,7 @@ export class Session {
|
||||
source?: object,
|
||||
): Promise<void> | undefined {
|
||||
switch (msg.type) {
|
||||
case "agent.create.cancel.request":
|
||||
case "creation.cancel.request":
|
||||
return this.handleAgentCreationCancel(msg);
|
||||
case "agent.create.request":
|
||||
return this.handleAgentCreation(msg, source);
|
||||
@@ -2963,22 +2965,29 @@ export class Session {
|
||||
}
|
||||
|
||||
private async handleAgentCreationCancel(
|
||||
message: Extract<SessionInboundMessage, { type: "agent.create.cancel.request" }>,
|
||||
message: Extract<SessionInboundMessage, { type: "creation.cancel.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// The originating execution-scoped capability cannot be reduced to ordinary grants.
|
||||
this.subagentOptions.subagentAuthority?.();
|
||||
const result = await this.creationService.cancelAgent(
|
||||
message.idempotencyKey,
|
||||
message.agentId,
|
||||
);
|
||||
const result = await this.creationService.cancel(message.creation);
|
||||
const serverId = this.subagentOptions.serverId;
|
||||
if (!serverId) throw new Error("Daemon identity is unavailable");
|
||||
this.emit({
|
||||
type: "agent.create.cancel.response",
|
||||
type: "creation.cancel.response",
|
||||
payload: {
|
||||
requestId: message.requestId,
|
||||
cancellation: { ...result, reservedAgent: { serverId, agentId: message.agentId } },
|
||||
cancellation: {
|
||||
...result,
|
||||
reservation:
|
||||
message.creation.kind === "workspace"
|
||||
? {
|
||||
kind: "workspace",
|
||||
reservedAgent: { serverId, agentId: message.creation.agentId },
|
||||
reservedWorkspace: { serverId, workspaceId: message.creation.workspaceId },
|
||||
}
|
||||
: { kind: "agent", reservedAgent: { serverId, agentId: message.creation.agentId } },
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -2997,14 +3006,84 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAgentClose(
|
||||
message: Extract<SessionInboundMessage, { type: "agent.close.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.subagentOptions.subagentAuthority?.();
|
||||
await closeAgentCommand({ agentManager: this.agentManager }, message.agentId);
|
||||
this.emit({ type: "agent.close.response", payload: { requestId: message.requestId } });
|
||||
} catch (error) {
|
||||
this.emit({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: message.requestId,
|
||||
requestType: message.type,
|
||||
code:
|
||||
error instanceof Error && "code" in error ? String(error.code) : "agent_close_failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAgentRunGet(
|
||||
message: Extract<SessionInboundMessage, { type: "agent.runs.get.request" }>,
|
||||
): Promise<void> {
|
||||
const run = await this.agentManager.getAgentRun(message.agentId, message.submissionId);
|
||||
this.emit({ type: "agent.runs.get.response", payload: { requestId: message.requestId, run } });
|
||||
try {
|
||||
this.subagentOptions.subagentAuthority?.();
|
||||
if (message.observe) {
|
||||
let stop = () => {};
|
||||
const owner = this.delivery.begin("agent-runs", undefined, () => stop(), message.agentId);
|
||||
try {
|
||||
stop = await this.agentManager.observeAgentRun(
|
||||
message.agentId,
|
||||
message.submissionId,
|
||||
(run) =>
|
||||
owner.emit({
|
||||
type: "agent.runs.get.response",
|
||||
payload: {
|
||||
requestId: message.requestId,
|
||||
subscriptionId: owner.id,
|
||||
run,
|
||||
},
|
||||
}),
|
||||
(run) =>
|
||||
owner.emit({
|
||||
type: "agent.runs.changed",
|
||||
payload: { subscriptionId: owner.id, run },
|
||||
}),
|
||||
);
|
||||
if (owner.signal.aborted) stop();
|
||||
} catch (error) {
|
||||
await owner.release();
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const run = await this.agentManager.getAgentRun(message.agentId, message.submissionId);
|
||||
this.emit({
|
||||
type: "agent.runs.get.response",
|
||||
payload: { requestId: message.requestId, run },
|
||||
});
|
||||
} catch (error) {
|
||||
this.emit({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: message.requestId,
|
||||
requestType: message.type,
|
||||
code:
|
||||
error instanceof Error && "code" in error
|
||||
? String(error.code)
|
||||
: "agent_run_read_failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchSubagentMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||
if (msg.type === "agent.close.request") return this.handleAgentClose(msg);
|
||||
if (msg.type === "agent.runs.get.request") return this.handleAgentRunGet(msg);
|
||||
if (
|
||||
msg.type === "agent.subagents.create.request" ||
|
||||
@@ -4271,10 +4350,29 @@ export class Session {
|
||||
? (await this.workspaceRegistry.get(id)) !== null
|
||||
: this.agentManager.getAgent(id) != null || (await this.agentStorage.get(id)) !== null;
|
||||
|
||||
private createRequestedAgent(
|
||||
private async createRequestedAgent(
|
||||
request: AgentCreateRequest,
|
||||
observer?: (snapshot: CreationSnapshot) => void,
|
||||
): Promise<CreationSnapshot> {
|
||||
// COMPAT(qualifiedSubagents): added in v0.12, remove after 2027-04-20.
|
||||
// Normalize legacy parent placement before the creation owner reserves its resource.
|
||||
const legacyParentId = request.callerAgentId ?? request.labels[PARENT_AGENT_ID_LABEL];
|
||||
if (legacyParentId && this.subagentOptions.subagents) {
|
||||
const key = request.idempotencyKey ?? request.requestId;
|
||||
const observing = await this.creationService.subscribe("agent", key, observer ?? (() => {}));
|
||||
try {
|
||||
await this.createLegacySubagent(
|
||||
{ ...request, type: "create_agent_request", idempotencyKey: key },
|
||||
request.agentId,
|
||||
);
|
||||
const receipt = await this.creationService.subscribe("agent", key, () => {});
|
||||
receipt.unsubscribe();
|
||||
if (!receipt.snapshot) throw new Error("Subagent creation owner omitted its receipt");
|
||||
return receipt.snapshot;
|
||||
} finally {
|
||||
observing.unsubscribe();
|
||||
}
|
||||
}
|
||||
const { requestId, type: _type, subscribe: _subscribe, idempotencyKey, ...intent } = request;
|
||||
return this.creationService.create(
|
||||
{
|
||||
@@ -4349,7 +4447,7 @@ export class Session {
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
|
||||
source?: object,
|
||||
): Promise<void> {
|
||||
const { requestId, subscribe, type: _type, idempotencyKey, ...intent } = request;
|
||||
const { requestId, subscribe } = request;
|
||||
const progress = subscribe
|
||||
? this.delivery.operation(
|
||||
(message) =>
|
||||
@@ -4365,67 +4463,8 @@ export class Session {
|
||||
) {
|
||||
throw new Error("Session is not authorized to create an agent");
|
||||
}
|
||||
const agentInput = request.agent;
|
||||
const creation = await this.creationService.create(
|
||||
{
|
||||
kind: "workspace",
|
||||
key: idempotencyKey ?? requestId,
|
||||
request: intent,
|
||||
workspaceId: request.workspaceId,
|
||||
agentId: agentInput?.agentId,
|
||||
hasAgent: Boolean(agentInput),
|
||||
hasPrompt: hasInitialCreationPrompt(agentInput),
|
||||
exists: this.creationResourceExists,
|
||||
provision: async (id) => {
|
||||
const descriptor = await this.createRequestedWorkspace(
|
||||
{
|
||||
...request,
|
||||
firstAgentContext: agentInput
|
||||
? { prompt: agentInput.initialPrompt, attachments: agentInput.attachments }
|
||||
: request.firstAgentContext,
|
||||
},
|
||||
id,
|
||||
);
|
||||
const workspace = await this.workspaceRegistry.get(descriptor.id);
|
||||
return {
|
||||
workspace: descriptor,
|
||||
setupSkippedReason: workspace?.untrustedSource
|
||||
? formatWorkspaceAutomationBlockedMessage(workspace.untrustedSource)
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
createAgent: agentInput
|
||||
? async (id, workspace, onReady) => {
|
||||
if (!workspace?.workspaceDirectory)
|
||||
throw new Error("Created workspace has no directory");
|
||||
const sourceCwd =
|
||||
request.source.kind === "directory"
|
||||
? request.source.path
|
||||
: await resolveWorktreeSourceCwd(request.source, this.projectRegistry);
|
||||
const relativeCwd = relative(resolve(sourceCwd), resolve(agentInput.config.cwd));
|
||||
if (
|
||||
relativeCwd === ".." ||
|
||||
relativeCwd.startsWith(`..${sep}`) ||
|
||||
isAbsolute(relativeCwd)
|
||||
)
|
||||
throw new Error("Agent directory must be inside the workspace source");
|
||||
return this.createSessionAgent(
|
||||
{
|
||||
...agentInput,
|
||||
type: "create_agent_request",
|
||||
requestId,
|
||||
config: {
|
||||
...agentInput.config,
|
||||
cwd: resolve(workspace.workspaceDirectory, relativeCwd),
|
||||
},
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
id,
|
||||
onReady,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
const creation = await this.createRequestedWorkspaceWithAgent(
|
||||
request,
|
||||
progress ? (snapshot) => progress.emit(this.creationUpdate(snapshot)) : undefined,
|
||||
);
|
||||
this.emitForSource(
|
||||
@@ -4466,6 +4505,114 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async createRequestedWorkspaceWithAgent(
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
|
||||
observer?: (snapshot: CreationSnapshot) => void,
|
||||
): Promise<CreationSnapshot> {
|
||||
const { requestId, type: _type, subscribe: _subscribe, idempotencyKey, ...intent } = request;
|
||||
const agentInput = request.agent;
|
||||
const parentId = agentInput?.callerAgentId ?? agentInput?.labels[PARENT_AGENT_ID_LABEL];
|
||||
const { subagents, subagentAuthority, serverId } = this.subagentOptions;
|
||||
if (agentInput && parentId && subagents && subagentAuthority && serverId) {
|
||||
const key = idempotencyKey ?? requestId;
|
||||
const observing = await this.creationService.subscribe(
|
||||
"workspace",
|
||||
key,
|
||||
observer ?? (() => {}),
|
||||
);
|
||||
try {
|
||||
const { callerAgentId: _caller, parent: _parent, ...input } = agentInput;
|
||||
const {
|
||||
type: _workspaceType,
|
||||
requestId: _workspaceRequestId,
|
||||
subscribe: _subscribeWorkspace,
|
||||
agent: _agent,
|
||||
...workspace
|
||||
} = request;
|
||||
const labels = { ...input.labels };
|
||||
delete labels[PARENT_AGENT_ID_LABEL];
|
||||
await subagents.create(
|
||||
{ serverId, agentId: parentId },
|
||||
{
|
||||
serverId,
|
||||
idempotencyKey: key,
|
||||
workspace,
|
||||
input: { ...input, labels, idempotencyKey: key },
|
||||
},
|
||||
subagentAuthority(),
|
||||
);
|
||||
const receipt = await this.creationService.subscribe("workspace", key, () => {});
|
||||
receipt.unsubscribe();
|
||||
if (!receipt.snapshot) throw new Error("Workspace creation owner omitted its receipt");
|
||||
return receipt.snapshot;
|
||||
} finally {
|
||||
observing.unsubscribe();
|
||||
}
|
||||
}
|
||||
return this.creationService.create(
|
||||
{
|
||||
kind: "workspace",
|
||||
key: idempotencyKey ?? requestId,
|
||||
request: intent,
|
||||
workspaceId: request.workspaceId,
|
||||
agentId: agentInput?.agentId,
|
||||
hasAgent: Boolean(agentInput),
|
||||
hasPrompt: hasInitialCreationPrompt(agentInput),
|
||||
exists: this.creationResourceExists,
|
||||
provision: async (id) => {
|
||||
const descriptor = await this.createRequestedWorkspace(
|
||||
{
|
||||
...request,
|
||||
firstAgentContext: agentInput
|
||||
? { prompt: agentInput.initialPrompt, attachments: agentInput.attachments }
|
||||
: request.firstAgentContext,
|
||||
},
|
||||
id,
|
||||
);
|
||||
const workspace = await this.workspaceRegistry.get(descriptor.id);
|
||||
return {
|
||||
workspace: descriptor,
|
||||
setupSkippedReason: workspace?.untrustedSource
|
||||
? formatWorkspaceAutomationBlockedMessage(workspace.untrustedSource)
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
createAgent: agentInput
|
||||
? async (id, workspace, onReady) => {
|
||||
if (!workspace?.workspaceDirectory)
|
||||
throw new Error("Created workspace has no directory");
|
||||
const sourceCwd =
|
||||
request.source.kind === "directory"
|
||||
? request.source.path
|
||||
: await resolveWorktreeSourceCwd(request.source, this.projectRegistry);
|
||||
const relativeCwd = relative(resolve(sourceCwd), resolve(agentInput.config.cwd));
|
||||
if (
|
||||
relativeCwd === ".." ||
|
||||
relativeCwd.startsWith(`..${sep}`) ||
|
||||
isAbsolute(relativeCwd)
|
||||
)
|
||||
throw new Error("Agent directory must be inside the workspace source");
|
||||
return this.createSessionAgent(
|
||||
{
|
||||
...agentInput,
|
||||
type: "create_agent_request",
|
||||
requestId,
|
||||
config: {
|
||||
...agentInput.config,
|
||||
cwd: resolve(workspace.workspaceDirectory, relativeCwd),
|
||||
},
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
id,
|
||||
onReady,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
observer,
|
||||
);
|
||||
}
|
||||
|
||||
private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise<void> {
|
||||
try {
|
||||
let agent: AgentSnapshotPayload;
|
||||
@@ -4560,7 +4707,7 @@ export class Session {
|
||||
{
|
||||
serverId,
|
||||
idempotencyKey: msg.idempotencyKey ?? msg.requestId,
|
||||
input: { ...input, labels, agentId },
|
||||
input: { ...input, labels, agentId, idempotencyKey: msg.idempotencyKey ?? msg.requestId },
|
||||
},
|
||||
subagentAuthority(),
|
||||
);
|
||||
@@ -4730,6 +4877,9 @@ export class Session {
|
||||
}
|
||||
|
||||
let config = request.config;
|
||||
// The worktree workflow already maps the requested subdirectory into its checkout.
|
||||
// Workspace identity remains separate from an existing agent's launch directory.
|
||||
const launchCwd = createdWorktree?.workspace.cwd ?? config.cwd;
|
||||
|
||||
const intent = await resolveCreateAgentIntent({
|
||||
explicitWorkspaceId: createdWorktree?.workspace.workspaceId ?? request.workspaceId,
|
||||
@@ -4739,13 +4889,13 @@ export class Session {
|
||||
labels: request.labels,
|
||||
resolveWorkspace: async (workspaceId) => {
|
||||
if (createdWorktree?.workspace.workspaceId === workspaceId) {
|
||||
return { workspaceId, cwd: createdWorktree.workspace.cwd };
|
||||
return { workspaceId, cwd: launchCwd };
|
||||
}
|
||||
const workspace = await this.workspaceRegistry.get(workspaceId);
|
||||
if (!workspace || workspace.archivedAt) {
|
||||
throw new Error(`Workspace ${workspaceId} not found`);
|
||||
}
|
||||
return { workspaceId, cwd: workspace.cwd };
|
||||
return { workspaceId, cwd: launchCwd };
|
||||
},
|
||||
createWorkspace: async () => ({
|
||||
workspaceId: await this.workspaceProvisioning.resolveOrCreateWorkspaceIdForCreateAgent({
|
||||
@@ -5007,7 +5157,7 @@ export class Session {
|
||||
this.sessionLogger.info({ agentId }, `Cancel request received for agent ${agentId}`);
|
||||
|
||||
try {
|
||||
await cancelAgentRunCommand(
|
||||
const { cancelled } = await cancelAgentRunCommand(
|
||||
{ agentManager: this.agentManager, logger: this.sessionLogger },
|
||||
agentId,
|
||||
);
|
||||
@@ -5020,6 +5170,7 @@ export class Session {
|
||||
requestId,
|
||||
agentId,
|
||||
agent: payload,
|
||||
cancelled,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
@@ -8488,6 +8639,13 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private legacyMessageSource(
|
||||
agentId: string | undefined,
|
||||
): import("./agent/agent-messages/index.js").AgentPromptSource | undefined {
|
||||
// COMPAT(local-message-source): added in v0.12, remove after 2027-04-20.
|
||||
return agentId ? { kind: "agent-message", agentId } : undefined;
|
||||
}
|
||||
|
||||
private async handleSendAgentMessageRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "send_agent_message_request" }>,
|
||||
): Promise<void> {
|
||||
@@ -8513,21 +8671,23 @@ export class Session {
|
||||
{
|
||||
agentId,
|
||||
messageId: msg.messageId,
|
||||
unarchive: msg.unarchive,
|
||||
activeTurnBehavior: msg.activeTurnBehavior,
|
||||
textPrefix: msg.text.slice(0, 80),
|
||||
},
|
||||
"agent.session.send_agent_message",
|
||||
);
|
||||
const send = async () => {
|
||||
const send = async (): Promise<AgentDispatch> => {
|
||||
const result = await sendPromptToAgent({
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
agentId,
|
||||
prompt,
|
||||
source: msg.sourceAgentId
|
||||
? { kind: "agent-message", agentId: msg.sourceAgentId }
|
||||
: undefined,
|
||||
source: msg.source
|
||||
? fromMessageSource(msg.source)
|
||||
: this.legacyMessageSource(msg.sourceAgentId),
|
||||
messageId: msg.messageId,
|
||||
unarchive: msg.unarchive,
|
||||
activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt",
|
||||
clearPendingPermissions: true,
|
||||
logger: this.sessionLogger,
|
||||
@@ -8539,23 +8699,37 @@ export class Session {
|
||||
this.delivery.requestSignal,
|
||||
);
|
||||
}
|
||||
return {
|
||||
disposition: result.disposition,
|
||||
tracking:
|
||||
result.disposition === "turn_started" || result.disposition === "steered"
|
||||
? "turn"
|
||||
: "unavailable",
|
||||
};
|
||||
};
|
||||
let dispatch: AgentDispatch | void;
|
||||
if (msg.messageId) {
|
||||
await this.messageReceipts.send({
|
||||
dispatch = await this.messageReceipts.send({
|
||||
agentId,
|
||||
messageId: msg.messageId,
|
||||
request: {
|
||||
prompt,
|
||||
sourceAgentId: msg.sourceAgentId,
|
||||
...(msg.source ? { source: msg.source } : {}),
|
||||
activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt",
|
||||
...(msg.unarchive !== undefined ? { unarchive: msg.unarchive } : {}),
|
||||
},
|
||||
prepare: async () => {
|
||||
if (!msg.sourceAgentId) await this.prepareAgentMessage(agentId, msg.text);
|
||||
if (!msg.sourceAgentId && !msg.source) {
|
||||
const recipient = await this.agentStorage.get(agentId);
|
||||
if (msg.unarchive !== false || !recipient?.archivedAt)
|
||||
await this.prepareAgentMessage(agentId, msg.text);
|
||||
}
|
||||
},
|
||||
send,
|
||||
});
|
||||
} else {
|
||||
await send();
|
||||
dispatch = await send();
|
||||
}
|
||||
|
||||
this.emit({
|
||||
@@ -8564,6 +8738,7 @@ export class Session {
|
||||
requestId: msg.requestId,
|
||||
agentId,
|
||||
accepted: true,
|
||||
...(dispatch ? { dispatch } : {}),
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
@@ -8577,6 +8752,12 @@ export class Session {
|
||||
agentId: resolved.agentId,
|
||||
accepted: false,
|
||||
error: errorToFriendlyMessage(error),
|
||||
...(error instanceof Error && "code" in error && typeof error.code === "string"
|
||||
? { code: error.code }
|
||||
: {}),
|
||||
...(error instanceof Error && "outcome" in error && error.outcome === "unknown"
|
||||
? { outcome: "unknown" as const }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SessionInboundMessageSchema } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
createMessageReceiptsStub,
|
||||
createTestCreationService,
|
||||
@@ -1053,13 +1054,20 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
|
||||
terminalManager: null,
|
||||
}),
|
||||
);
|
||||
await session.handleMessage({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-create-child",
|
||||
config: { provider: "codex", cwd: child },
|
||||
attachments: [],
|
||||
});
|
||||
await session.handleMessage(
|
||||
SessionInboundMessageSchema.parse({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-create-child",
|
||||
config: { provider: "codex", cwd: child },
|
||||
attachments: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
emitted.find(
|
||||
(message) => message.type === "status" && message.payload.status === "agent_create_failed",
|
||||
),
|
||||
).toBeUndefined();
|
||||
const [createdAgent] = agentManager.listAgents();
|
||||
expect(createdAgent?.cwd).toBe(child);
|
||||
const createdWorkspace = await workspaceRegistry.get(createdAgent!.workspaceId!);
|
||||
@@ -1218,14 +1226,21 @@ test("create_agent_request launches from an exact subdirectory in a created work
|
||||
terminalManager: null,
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-create-worktree-child",
|
||||
config: { provider: "codex", cwd: child },
|
||||
attachments: [],
|
||||
worktree: { mode: "branch-off", newBranch: "feature/created-worktree" },
|
||||
});
|
||||
await session.handleMessage(
|
||||
SessionInboundMessageSchema.parse({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-create-worktree-child",
|
||||
config: { provider: "codex", cwd: child },
|
||||
attachments: [],
|
||||
worktree: { mode: "branch-off", newBranch: "feature/created-worktree" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
emitted.find(
|
||||
(message) => message.type === "status" && message.payload.status === "agent_create_failed",
|
||||
),
|
||||
).toBeUndefined();
|
||||
const [createdAgent] = agentManager.listAgents();
|
||||
const createdWorktreeRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd: createdAgent!.cwd,
|
||||
@@ -1353,14 +1368,16 @@ test("create_agent_request does not title an existing workspace from the agent p
|
||||
}),
|
||||
);
|
||||
|
||||
await session.handleMessage({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-create-existing-title",
|
||||
workspaceId: "ws-existing",
|
||||
config: { provider: "codex", cwd },
|
||||
initialPrompt: "Fix login bug\nwith better validation",
|
||||
attachments: [],
|
||||
});
|
||||
await session.handleMessage(
|
||||
SessionInboundMessageSchema.parse({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-create-existing-title",
|
||||
workspaceId: "ws-existing",
|
||||
config: { provider: "codex", cwd },
|
||||
initialPrompt: "Fix login bug\nwith better validation",
|
||||
attachments: [],
|
||||
}),
|
||||
);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const [createdAgent] = agentManager.listAgents();
|
||||
|
||||
@@ -237,8 +237,16 @@ export class Subagents {
|
||||
if (snapshot.archivedAt)
|
||||
throw failure("invalid_parent", "Cannot attach a child to an archived parent");
|
||||
creation = SubagentCreateInputSchema.parse(creation);
|
||||
const id = createHash("sha256")
|
||||
.update(JSON.stringify([authority.sourceId, parent, creation.idempotencyKey]))
|
||||
.digest("hex");
|
||||
let workspaceId = creation.input.workspaceId;
|
||||
if (!workspaceId) {
|
||||
if (creation.workspace) {
|
||||
if (workspaceId)
|
||||
throw failure("invalid_placement", "Choose a new workspace or an existing workspace");
|
||||
workspaceId = creation.workspace.workspaceId ?? `wks_${id.slice(0, 16)}`;
|
||||
creation.workspace.workspaceId = workspaceId;
|
||||
} else if (!workspaceId) {
|
||||
if (creation.serverId !== parent.serverId)
|
||||
throw failure("workspace_required", "Choose a workspace on the destination host");
|
||||
workspaceId = snapshot.workspaceId;
|
||||
@@ -246,9 +254,6 @@ export class Subagents {
|
||||
}
|
||||
creation.input.workspaceId = workspaceId;
|
||||
const fingerprint = JSON.stringify(creation);
|
||||
const id = createHash("sha256")
|
||||
.update(JSON.stringify([authority.sourceId, parent, creation.idempotencyKey]))
|
||||
.digest("hex");
|
||||
const record = await this.transact(async () => {
|
||||
const existing = this.records.get(id);
|
||||
if (existing) {
|
||||
@@ -303,6 +308,9 @@ export class Subagents {
|
||||
}
|
||||
return running;
|
||||
}
|
||||
private creationKey(record: RelationshipRecord): string {
|
||||
return record.creation.input.idempotencyKey ?? `subagent:${record.row.id}`;
|
||||
}
|
||||
private async createChild(record: RelationshipRecord): Promise<AgentAddress> {
|
||||
const { row, creation } = record;
|
||||
const creationIntent = record.intent;
|
||||
@@ -310,9 +318,8 @@ export class Subagents {
|
||||
const runtime = await this.runtime(record.authority);
|
||||
const api = await runtime.client(row.child.serverId);
|
||||
try {
|
||||
const workspace = api.workspaces.ref(creation.input.workspaceId!);
|
||||
const { config, initialPrompt, ...input } = creation.input;
|
||||
const agent = await workspace.agents.create({
|
||||
const agentOptions = {
|
||||
...input,
|
||||
agentId: row.child.agentId,
|
||||
config: {
|
||||
@@ -321,9 +328,26 @@ export class Subagents {
|
||||
},
|
||||
parent: row.parent,
|
||||
prompt: initialPrompt,
|
||||
idempotencyKey: `subagent:${row.id}`,
|
||||
clientMessageId: `subagent:${row.id}:initial`,
|
||||
});
|
||||
clientMessageId: input.clientMessageId ?? `subagent:${row.id}:initial`,
|
||||
};
|
||||
let agent;
|
||||
if (creation.workspace) {
|
||||
await api.workspaces.create({
|
||||
...creation.workspace,
|
||||
workspaceId: creation.input.workspaceId,
|
||||
idempotencyKey: this.creationKey(record),
|
||||
agent: { ...agentOptions, cwd: config.cwd },
|
||||
});
|
||||
agent = api.agents.ref(row.child.agentId);
|
||||
await agent.refresh();
|
||||
} else {
|
||||
const workspace = api.workspaces.ref(creation.input.workspaceId!);
|
||||
agent = await workspace.agents.create({
|
||||
...agentOptions,
|
||||
...(config.cwd ? { cwd: config.cwd } : {}),
|
||||
idempotencyKey: this.creationKey(record),
|
||||
});
|
||||
}
|
||||
await this.transact(async () => {
|
||||
// Creation confirms a resource; it never owns a newer lifecycle decision.
|
||||
if (!terminalConfirmation(record)) record.confirmed = "attached";
|
||||
@@ -630,7 +654,10 @@ export class Subagents {
|
||||
await api.dispose();
|
||||
return null;
|
||||
}
|
||||
const observation = api.creations.observeAgent(`subagent:${record.row.id}`);
|
||||
const observation = api.creations.observe({
|
||||
kind: record.creation.workspace ? "workspace" : "agent",
|
||||
idempotencyKey: this.creationKey(record),
|
||||
});
|
||||
let latest: CreationSnapshot | null = null;
|
||||
const accept = (snapshot: CreationSnapshot | null) => {
|
||||
latest = snapshot;
|
||||
@@ -681,8 +708,11 @@ export class Subagents {
|
||||
const destination = await runtime.client(record.row.child.serverId);
|
||||
try {
|
||||
if (record.intent !== intent) return;
|
||||
const cancellation = await destination.creations.cancelAgent({
|
||||
idempotencyKey: `subagent:${record.row.id}`,
|
||||
const cancellation = await destination.creations.cancel({
|
||||
...(record.creation.workspace
|
||||
? { kind: "workspace" as const, workspaceId: record.creation.input.workspaceId! }
|
||||
: { kind: "agent" as const }),
|
||||
idempotencyKey: this.creationKey(record),
|
||||
agentId: record.row.child.agentId,
|
||||
});
|
||||
if (cancellation.status === "canceled") {
|
||||
|
||||
@@ -58,18 +58,24 @@ interface FakeAgentSessionOptions {
|
||||
sessionId?: string;
|
||||
memoryMarker?: string | null;
|
||||
closeSession?: () => Promise<void>;
|
||||
beforeSetFeature?: (featureId: string, value: unknown) => Promise<void>;
|
||||
onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void;
|
||||
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
|
||||
historyEvents?: readonly AgentStreamEvent[];
|
||||
steerActiveTurn?: AgentSession["steerActiveTurn"];
|
||||
tryHandleOutOfBand?: AgentSession["tryHandleOutOfBand"];
|
||||
}
|
||||
|
||||
export interface TestAgentClientOptions {
|
||||
beforeCreateSession?: (config: AgentSessionConfig) => Promise<void>;
|
||||
closeSession?: () => Promise<void>;
|
||||
beforeSetFeature?: (featureId: string, value: unknown) => Promise<void>;
|
||||
onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void;
|
||||
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
|
||||
supportsMcpServers?: boolean;
|
||||
historyEvents?: readonly AgentStreamEvent[];
|
||||
steerActiveTurn?: AgentSession["steerActiveTurn"];
|
||||
tryHandleOutOfBand?: AgentSession["tryHandleOutOfBand"];
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
@@ -327,6 +333,8 @@ function buildLargeTimelineItem(input: {
|
||||
}
|
||||
|
||||
class FakeAgentSession implements AgentSession {
|
||||
readonly steerActiveTurn: AgentSession["steerActiveTurn"];
|
||||
readonly tryHandleOutOfBand: AgentSession["tryHandleOutOfBand"];
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
readonly id: string;
|
||||
private readonly providerName: string;
|
||||
@@ -341,6 +349,7 @@ class FakeAgentSession implements AgentSession {
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
|
||||
private readonly closeSession: (() => Promise<void>) | undefined;
|
||||
private readonly beforeSetFeature: TestAgentClientOptions["beforeSetFeature"];
|
||||
private readonly onStartTurn: TestAgentClientOptions["onStartTurn"];
|
||||
private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | undefined;
|
||||
private readonly historyEvents: readonly AgentStreamEvent[] | undefined;
|
||||
@@ -355,9 +364,12 @@ class FakeAgentSession implements AgentSession {
|
||||
this.id = options.sessionId ?? randomUUID();
|
||||
this.memoryMarker = options.memoryMarker ?? null;
|
||||
this.closeSession = options.closeSession;
|
||||
this.beforeSetFeature = options.beforeSetFeature;
|
||||
this.onStartTurn = options.onStartTurn;
|
||||
this.beforeTurnComplete = options.beforeTurnComplete;
|
||||
this.historyEvents = options.historyEvents;
|
||||
this.steerActiveTurn = options.steerActiveTurn;
|
||||
this.tryHandleOutOfBand = options.tryHandleOutOfBand;
|
||||
this.historyPath = path.join(
|
||||
tmpdir(),
|
||||
"paseo-fake-provider-history",
|
||||
@@ -865,6 +877,7 @@ class FakeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
await this.beforeSetFeature?.(featureId, value);
|
||||
this.config.featureValues = {
|
||||
...this.config.featureValues,
|
||||
[featureId]: value,
|
||||
@@ -1232,9 +1245,12 @@ class FakeAgentClient implements AgentClient {
|
||||
config: { ...config },
|
||||
supportsMcpServers: this.options.supportsMcpServers,
|
||||
closeSession: this.options.closeSession,
|
||||
beforeSetFeature: this.options.beforeSetFeature,
|
||||
onStartTurn: this.options.onStartTurn,
|
||||
beforeTurnComplete: this.options.beforeTurnComplete,
|
||||
historyEvents: this.options.historyEvents,
|
||||
steerActiveTurn: this.options.steerActiveTurn,
|
||||
tryHandleOutOfBand: this.options.tryHandleOutOfBand,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1259,9 +1275,12 @@ class FakeAgentClient implements AgentClient {
|
||||
sessionId: handle.sessionId,
|
||||
memoryMarker: typeof marker === "string" ? marker : null,
|
||||
closeSession: this.options.closeSession,
|
||||
beforeSetFeature: this.options.beforeSetFeature,
|
||||
onStartTurn: this.options.onStartTurn,
|
||||
beforeTurnComplete: this.options.beforeTurnComplete,
|
||||
historyEvents: this.options.historyEvents,
|
||||
steerActiveTurn: this.options.steerActiveTurn,
|
||||
tryHandleOutOfBand: this.options.tryHandleOutOfBand,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -281,6 +281,7 @@ function createVoiceAssistantWebSocketServer(params: {
|
||||
const { httpServer, broker } = params;
|
||||
const agentManager = {
|
||||
setAgentAttentionCallback() {},
|
||||
onAgentArchived: () => () => {},
|
||||
subscribe: () => () => {},
|
||||
getMetricsSnapshot: () => ({
|
||||
total: 0,
|
||||
|
||||
@@ -87,6 +87,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
||||
const agentManager = {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
setAgentAttentionCallback: vi.fn(),
|
||||
onAgentArchived: () => () => {},
|
||||
getAgent: vi.fn(() => ({ workspaceId: WORKSPACE_ID, pendingPermissions: new Map() })),
|
||||
getLastAssistantMessage: vi.fn(async () => null),
|
||||
getMetricsSnapshot: vi.fn(() => ({
|
||||
|
||||
@@ -269,6 +269,7 @@ function createServer(options?: {
|
||||
createStub<AgentManager>({
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
setAgentAttentionCallback: vi.fn(),
|
||||
onAgentArchived: () => () => {},
|
||||
getAgent: vi.fn(() => null),
|
||||
getMetricsSnapshot: vi.fn(() => ({
|
||||
totalAgents: 0,
|
||||
|
||||
@@ -118,6 +118,7 @@ function createServer(terminalManager: TerminalManager, workspaceRegistry?: Work
|
||||
const pushNotifications = new RecordingPushNotificationSender();
|
||||
const agentManager = {
|
||||
setAgentAttentionCallback: vi.fn(),
|
||||
onAgentArchived: () => () => {},
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getAgent: vi.fn(() => null),
|
||||
getLastAssistantMessage: vi.fn(async () => null),
|
||||
|
||||
@@ -534,6 +534,7 @@ function requireWebSocketServices(params: {
|
||||
*/
|
||||
export class VoiceAssistantWebSocketServer {
|
||||
private readonly subagents: Subagents;
|
||||
private readonly stopAgentArchived: () => void;
|
||||
private ownerAdmission: SessionAdmission = OWNER_SESSION_ADMISSION;
|
||||
private readonly hostRuntimes = new Set<Awaited<ReturnType<typeof createDaemonHostRuntime>>>();
|
||||
private readonly ownedSocketSources = new WeakMap<WebSocketLike, string>();
|
||||
@@ -765,7 +766,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.logger.warn({ err: error }, "Failed to reconcile subagent host availability"),
|
||||
);
|
||||
});
|
||||
this.agentManager.setAgentArchivedCallback(async (agentId) => {
|
||||
this.stopAgentArchived = this.agentManager.onAgentArchived(async (agentId) => {
|
||||
const record = await this.agentStorage.get(agentId);
|
||||
await this.subagents.parentArchived(
|
||||
{ serverId, agentId },
|
||||
@@ -1170,6 +1171,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
public async close(): Promise<void> {
|
||||
this.prepareForShutdown();
|
||||
this.stopAgentArchived();
|
||||
await this.subagents.close();
|
||||
await Promise.allSettled([...this.hostRuntimes].map((runtime) => runtime.dispose()));
|
||||
this.hostRuntimes.clear();
|
||||
|
||||
Reference in New Issue
Block a user