fix(agents): retain creation identity and dispatch evidence across hosts

This commit is contained in:
Mohamed Boudra
2026-10-20 12:15:51 +02:00
parent b1b5010bd2
commit 702bc942b7
35 changed files with 2411 additions and 726 deletions
+23
View File
@@ -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,
});
},
};
}
+76 -20
View File
@@ -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> {
+2
View File
@@ -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
View File
@@ -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) {
+36
View File
@@ -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)),
};
}
+14
View File
@@ -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"];
}
+10
View File
@@ -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>;
+11 -1
View File
@@ -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() }),
});
+66 -15
View File
@@ -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");
+144 -28
View File
@@ -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",
+2 -1
View File
@@ -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 },
});
+43 -27
View File
@@ -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;
}
}
+267 -86
View File
@@ -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();
+42 -12
View File
@@ -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();