mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 04:03:05 +00:00
Let Hub continue agents through the standard daemon API (#4354)
* feat(hub): continue agents through standard daemon operations Authorize ordinary agent and workspace recovery operations with hub.execute. Keep conversation routing in Hub and persist generic creation and message receipts so retries cannot silently duplicate work. * fix(hub): expose custom providers to continuing clients Allow Hub to advertise provider support independently of the app version. Document the companion continuation policy and migration. Preserve newly observed files in the native watcher inventory so coalesced deletions remain visible to reconciliation, addressing the Windows CI failure. * fix(hub): allow retries after confirmed local failures Prepare message loading before recording dispatch intent. Remove failed creation receipts only when creation cleanup completed and no live or stored agent exists. Keep ambiguous provider deliveries fail-closed. Exercise native file observation with real filesystem dependencies.
This commit is contained in:
@@ -55,6 +55,7 @@ export type EnsureWorkspaceForCreate = (
|
||||
|
||||
export interface CreateAgentFromSessionInput {
|
||||
kind: "session";
|
||||
agentId?: string;
|
||||
config: AgentSessionConfig;
|
||||
workspaceId: string;
|
||||
worktreeName?: string;
|
||||
@@ -181,7 +182,7 @@ export async function createAgentCommand(
|
||||
|
||||
const snapshot = await dependencies.agentManager.createAgent(
|
||||
resolved.config,
|
||||
undefined,
|
||||
input.kind === "session" ? input.agentId : undefined,
|
||||
resolved.createOptions,
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { AgentRequests } from "./index.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
async function fixture() {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "agent-requests-"));
|
||||
directories.push(directory);
|
||||
return { directory, requests: new AgentRequests(directory) };
|
||||
}
|
||||
|
||||
test("concurrent and reconstructed creates return the same durable agent", async () => {
|
||||
const { requests, directory } = await fixture();
|
||||
const agents = new Set<string>();
|
||||
let creations = 0;
|
||||
const input = {
|
||||
key: "create-1",
|
||||
request: { provider: "test", cwd: "/project" },
|
||||
findAgent: async (id: string) => agents.has(id),
|
||||
create: async (id: string) => {
|
||||
creations++;
|
||||
agents.add(id);
|
||||
},
|
||||
};
|
||||
const ids = await Promise.all([requests.create(input), requests.create(input)]);
|
||||
expect(ids[0]).toBe(ids[1]);
|
||||
expect(await new AgentRequests(directory).create(input)).toBe(ids[0]);
|
||||
expect(creations).toBe(1);
|
||||
});
|
||||
|
||||
test("recovers creation when the agent was persisted before acknowledgement failed", async () => {
|
||||
const { requests, directory } = await fixture();
|
||||
const agents = new Set<string>();
|
||||
const input = {
|
||||
key: "lost-create",
|
||||
request: {},
|
||||
findAgent: async (id: string) => agents.has(id),
|
||||
create: async (id: string) => {
|
||||
agents.add(id);
|
||||
throw new Error("acknowledgement lost");
|
||||
},
|
||||
};
|
||||
await expect(requests.create(input)).rejects.toThrow("acknowledgement lost");
|
||||
expect(await new AgentRequests(directory).create(input)).toBe([...agents][0]);
|
||||
expect(agents.size).toBe(1);
|
||||
});
|
||||
|
||||
test("reusing a create key with different configuration is a conflict", async () => {
|
||||
const { requests } = await fixture();
|
||||
const input = {
|
||||
key: "key",
|
||||
request: { model: "a" },
|
||||
findAgent: async () => true,
|
||||
create: async () => {},
|
||||
};
|
||||
await requests.create(input);
|
||||
await expect(requests.create({ ...input, request: { model: "b" } })).rejects.toThrow(
|
||||
"agent_request_key_conflict",
|
||||
);
|
||||
});
|
||||
|
||||
test("message retries survive reconstruction without submitting twice", async () => {
|
||||
const { requests, directory } = await fixture();
|
||||
let deliveries = 0;
|
||||
const input = {
|
||||
agentId: "agent",
|
||||
messageId: "arrival",
|
||||
request: { text: "hello" },
|
||||
send: async () => {
|
||||
deliveries++;
|
||||
},
|
||||
};
|
||||
await Promise.all([requests.send(input), requests.send(input)]);
|
||||
await new AgentRequests(directory).send(input);
|
||||
expect(deliveries).toBe(1);
|
||||
await requests.send({ ...input, agentId: "another" });
|
||||
expect(deliveries).toBe(2);
|
||||
});
|
||||
|
||||
test("ambiguous provider delivery is never blindly replayed after restart", async () => {
|
||||
const { requests, directory } = await fixture();
|
||||
let deliveries = 0;
|
||||
const input = {
|
||||
agentId: "agent",
|
||||
messageId: "arrival",
|
||||
request: {},
|
||||
send: async () => {
|
||||
deliveries++;
|
||||
throw new Error("connection lost");
|
||||
},
|
||||
};
|
||||
await expect(requests.send(input)).rejects.toThrow("connection lost");
|
||||
await expect(new AgentRequests(directory).send(input)).rejects.toThrow(
|
||||
"agent_request_outcome_unknown",
|
||||
);
|
||||
expect(deliveries).toBe(1);
|
||||
});
|
||||
|
||||
test("a creation failure with no stored agent can be retried", async () => {
|
||||
const { requests } = await fixture();
|
||||
let available = false;
|
||||
const input = {
|
||||
key: "unavailable",
|
||||
request: {},
|
||||
findAgent: async () => false,
|
||||
create: async () => {
|
||||
if (!available) throw new Error("provider unavailable");
|
||||
},
|
||||
};
|
||||
await expect(requests.create(input)).rejects.toThrow("provider unavailable");
|
||||
available = true;
|
||||
await expect(requests.create(input)).resolves.toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
test("failed local message preparation does not leave an ambiguous receipt", async () => {
|
||||
const { requests, directory } = await fixture();
|
||||
let available = false;
|
||||
let sends = 0;
|
||||
const input = {
|
||||
agentId: "agent",
|
||||
messageId: "message",
|
||||
request: {},
|
||||
prepare: async () => {
|
||||
if (!available) throw new Error("load failed");
|
||||
},
|
||||
send: async () => {
|
||||
sends++;
|
||||
},
|
||||
};
|
||||
await expect(requests.send(input)).rejects.toThrow("load failed");
|
||||
available = true;
|
||||
await new AgentRequests(directory).send(input);
|
||||
available = false;
|
||||
await requests.send(input);
|
||||
expect(sends).toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { writeJsonFileAtomic } from "../../atomic-file.js";
|
||||
|
||||
const ReceiptSchema = z.object({
|
||||
fingerprint: z.string(),
|
||||
agentId: z.string(),
|
||||
state: z.enum(["pending", "completed"]),
|
||||
});
|
||||
type Receipt = z.infer<typeof ReceiptSchema>;
|
||||
|
||||
/** One daemon-owned request journal, shared by all of its socket sessions. */
|
||||
export class AgentRequests {
|
||||
private readonly pending = new Map<string, Promise<string>>();
|
||||
|
||||
constructor(private readonly directory: string) {}
|
||||
|
||||
create(input: {
|
||||
key: string;
|
||||
request: unknown;
|
||||
findAgent: (agentId: string) => Promise<boolean>;
|
||||
create: (agentId: string) => Promise<void>;
|
||||
}): Promise<string> {
|
||||
return this.execute(["create", input.key], input.request, {
|
||||
agentId: randomUUID(),
|
||||
recover: input.findAgent,
|
||||
run: input.create,
|
||||
retrySafe: async (agentId) => !(await input.findAgent(agentId)),
|
||||
});
|
||||
}
|
||||
|
||||
async send(input: {
|
||||
agentId: string;
|
||||
messageId: string;
|
||||
request: unknown;
|
||||
send: () => Promise<void>;
|
||||
prepare?: () => Promise<void>;
|
||||
}): Promise<void> {
|
||||
await this.execute(["send", input.agentId, input.messageId], input.request, {
|
||||
agentId: input.agentId,
|
||||
// A provider call can take effect before the daemon records its outcome.
|
||||
// Never repeat that call merely because a process died in this window.
|
||||
recover: async () => false,
|
||||
run: input.send,
|
||||
prepare: input.prepare,
|
||||
});
|
||||
}
|
||||
|
||||
private execute(
|
||||
identity: string[],
|
||||
request: unknown,
|
||||
operation: {
|
||||
agentId: string;
|
||||
recover: (agentId: string) => Promise<boolean>;
|
||||
run: (agentId: string) => Promise<void>;
|
||||
prepare?: (() => Promise<void>) | undefined;
|
||||
retrySafe?: (agentId: string) => Promise<boolean>;
|
||||
},
|
||||
): Promise<string> {
|
||||
const key = digest(identity);
|
||||
const fingerprint = digest(request);
|
||||
const previous = this.pending.get(key);
|
||||
// Serialize even conflicting requests: each caller validates its own fingerprint.
|
||||
const result = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(() =>
|
||||
this.executeOnce(key, fingerprint, operation),
|
||||
);
|
||||
this.pending.set(key, result);
|
||||
void result
|
||||
.finally(() => {
|
||||
if (this.pending.get(key) === result) this.pending.delete(key);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async executeOnce(
|
||||
key: string,
|
||||
fingerprint: string,
|
||||
operation: {
|
||||
agentId: string;
|
||||
recover: (agentId: string) => Promise<boolean>;
|
||||
run: (agentId: string) => Promise<void>;
|
||||
prepare?: (() => Promise<void>) | undefined;
|
||||
retrySafe?: (agentId: string) => Promise<boolean>;
|
||||
},
|
||||
): Promise<string> {
|
||||
const file = path.join(this.directory, `${key}.json`);
|
||||
const existing = await readReceipt(file);
|
||||
if (existing) {
|
||||
if (existing.fingerprint !== fingerprint) throw new Error("agent_request_key_conflict");
|
||||
if (existing.state === "completed") return existing.agentId;
|
||||
if (!(await operation.recover(existing.agentId))) {
|
||||
throw new Error("agent_request_outcome_unknown");
|
||||
}
|
||||
await writeJsonFileAtomic(file, { ...existing, state: "completed" });
|
||||
return existing.agentId;
|
||||
}
|
||||
await operation.prepare?.();
|
||||
const receipt: Receipt = { fingerprint, agentId: operation.agentId, state: "pending" };
|
||||
await writeJsonFileAtomic(file, receipt);
|
||||
try {
|
||||
await operation.run(receipt.agentId);
|
||||
} catch (error) {
|
||||
// Keyed creation has no initial prompt. Once its normal cleanup finished,
|
||||
// absence of an agent confirms that retrying cannot duplicate one.
|
||||
if (await operation.retrySafe?.(receipt.agentId)) await rm(file, { force: true });
|
||||
throw error;
|
||||
}
|
||||
await writeJsonFileAtomic(file, { ...receipt, state: "completed" });
|
||||
return receipt.agentId;
|
||||
}
|
||||
}
|
||||
|
||||
async function readReceipt(file: string): Promise<Receipt | null> {
|
||||
try {
|
||||
return ReceiptSchema.parse(JSON.parse(await readFile(file, "utf8")));
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
return createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify(value, (_key, candidate: unknown) => {
|
||||
if (candidate !== null && typeof candidate === "object" && !Array.isArray(candidate)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(candidate).sort(([a], [b]) => a.localeCompare(b)),
|
||||
);
|
||||
}
|
||||
return candidate;
|
||||
}),
|
||||
)
|
||||
.digest("hex");
|
||||
}
|
||||
@@ -26,6 +26,11 @@ function inboundMessage(type: SessionInboundMessage["type"]): SessionInboundMess
|
||||
}
|
||||
|
||||
function outboundMessage(type: SessionOutboundMessage["type"]): SessionOutboundMessage {
|
||||
if (type === "status")
|
||||
return {
|
||||
type,
|
||||
payload: { status: "agent_create_failed", error: "test", requestId: "test" },
|
||||
} as SessionOutboundMessage;
|
||||
return { type } as SessionOutboundMessage;
|
||||
}
|
||||
|
||||
@@ -69,6 +74,45 @@ describe("SessionAuthorization", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("Hub can operate ordinary agents and recover workspaces without daemon administration", () => {
|
||||
const authorization = new SessionAuthorization(["hub.execute"]);
|
||||
for (const type of [
|
||||
"create_agent_request",
|
||||
"send_agent_message_request",
|
||||
"fetch_agent_request",
|
||||
"agent.timeline.set_subscription.request",
|
||||
"workspace.recovery.inspect.request",
|
||||
"workspace.recovery.restore.request",
|
||||
] as const) {
|
||||
expect(authorization.allowsInbound(inboundMessage(type))).toBe(true);
|
||||
}
|
||||
for (const type of [
|
||||
"status",
|
||||
"agent_update",
|
||||
"agent_stream",
|
||||
"send_agent_message_response",
|
||||
"workspace.recovery.restore.response",
|
||||
] as const) {
|
||||
expect(authorization.allowsOutbound(outboundMessage(type))).toBe(true);
|
||||
}
|
||||
for (const type of [
|
||||
"restart_server_request",
|
||||
"terminal_input",
|
||||
"hub.management.daemon.permissions.update.request",
|
||||
] as const) {
|
||||
expect(authorization.allowsInbound(inboundMessage(type))).toBe(false);
|
||||
}
|
||||
expect(
|
||||
authorization.allowsOutbound({
|
||||
type: "status",
|
||||
payload: { status: "shutdown_requested", clientId: "owner", requestId: "shutdown" },
|
||||
}),
|
||||
).toBe(false);
|
||||
authorization.replacePermissions([]);
|
||||
expect(authorization.allowsInbound(inboundMessage("send_agent_message_request"))).toBe(false);
|
||||
expect(authorization.allowsOutbound(outboundMessage("agent_update"))).toBe(false);
|
||||
});
|
||||
|
||||
test("correlated authorization errors can always be emitted", () => {
|
||||
const authorization = new SessionAuthorization([]);
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export class SessionAuthorization {
|
||||
}
|
||||
|
||||
allowsOutbound(message: SessionOutboundMessage): boolean {
|
||||
return this.allows(requiredPermissionForOutbound(message.type));
|
||||
return this.allows(requiredPermissionForOutbound(message));
|
||||
}
|
||||
|
||||
replacePermissions(permissions: readonly DaemonPermission[]): void {
|
||||
|
||||
@@ -7,7 +7,7 @@ export type PermissionRequirement = DaemonPermission | readonly DaemonPermission
|
||||
|
||||
const INBOUND_PERMISSION = {
|
||||
abort_request: "workspace.write",
|
||||
"agent.config.apply.request": "workspace.write",
|
||||
"agent.config.apply.request": ["workspace.write", "hub.execute"],
|
||||
"agent.detach.request": "workspace.write",
|
||||
"agent.fork_context.request": "workspace.read",
|
||||
"agent.provider_subagents.list.request": "workspace.read",
|
||||
@@ -20,14 +20,14 @@ const INBOUND_PERMISSION = {
|
||||
"agent.skills.uninstall.request": "daemon.manage",
|
||||
"agent.timeline.list_prompts.request": "workspace.read",
|
||||
"agent.timeline.append.request": "workspace.write",
|
||||
"agent.timeline.set_subscription.request": "workspace.read",
|
||||
"agent.timeline.set_subscription.request": ["workspace.read", "hub.execute"],
|
||||
agent_permission_response: "workspace.write",
|
||||
archive_agent_request: "workspace.write",
|
||||
archive_workspace_request: "workspace.manage",
|
||||
archive_agent_request: ["workspace.write", "hub.execute"],
|
||||
archive_workspace_request: ["workspace.manage", "hub.execute"],
|
||||
audio_played: "workspace.write",
|
||||
branch_suggestions_request: "workspace.read",
|
||||
"browser.automation.execute.response": "workspace.write",
|
||||
cancel_agent_request: "workspace.write",
|
||||
cancel_agent_request: ["workspace.write", "hub.execute"],
|
||||
capture_terminal_request: "workspace.read",
|
||||
"chat/create": "workspace.write",
|
||||
"chat/delete": "workspace.write",
|
||||
@@ -58,7 +58,7 @@ const INBOUND_PERMISSION = {
|
||||
clear_agent_attention: "workspace.write",
|
||||
client_heartbeat: "workspace.read",
|
||||
close_items_request: "workspace.manage",
|
||||
create_agent_request: "workspace.write",
|
||||
create_agent_request: ["workspace.write", "hub.execute"],
|
||||
create_paseo_worktree_request: "workspace.manage",
|
||||
create_terminal_request: "workspace.write",
|
||||
"daemon.config.reload.request": "daemon.manage",
|
||||
@@ -73,11 +73,11 @@ const INBOUND_PERMISSION = {
|
||||
dictation_stream_start: "workspace.write",
|
||||
directory_suggestions_request: "workspace.read",
|
||||
fetch_agent_history_request: "workspace.read",
|
||||
fetch_agent_request: "workspace.read",
|
||||
fetch_agent_timeline_request: "workspace.read",
|
||||
fetch_agents_request: "workspace.read",
|
||||
fetch_agent_request: ["workspace.read", "hub.execute"],
|
||||
fetch_agent_timeline_request: ["workspace.read", "hub.execute"],
|
||||
fetch_agents_request: ["workspace.read", "hub.execute"],
|
||||
fetch_recent_provider_sessions_request: "workspace.read",
|
||||
fetch_workspaces_request: "workspace.read",
|
||||
fetch_workspaces_request: ["workspace.read", "hub.execute"],
|
||||
"file.upload.request": "workspace.write",
|
||||
file_download_token_request: "workspace.read",
|
||||
file_explorer_request: "workspace.read",
|
||||
@@ -159,7 +159,7 @@ const INBOUND_PERMISSION = {
|
||||
"schedule/resume": "automation.manage",
|
||||
"schedule/run-once": "automation.manage",
|
||||
"schedule/update": "automation.manage",
|
||||
send_agent_message_request: "workspace.write",
|
||||
send_agent_message_request: ["workspace.write", "hub.execute"],
|
||||
set_agent_feature_request: "workspace.write",
|
||||
set_agent_mode_request: "workspace.write",
|
||||
set_agent_model_request: "workspace.write",
|
||||
@@ -192,8 +192,8 @@ const INBOUND_PERMISSION = {
|
||||
"workspace.label.list.request": "workspace.read",
|
||||
"workspace.label.update.request": "workspace.manage",
|
||||
"workspace.pin.set.request": "workspace.manage",
|
||||
"workspace.recovery.inspect.request": "workspace.read",
|
||||
"workspace.recovery.restore.request": "workspace.manage",
|
||||
"workspace.recovery.inspect.request": ["workspace.read", "hub.execute"],
|
||||
"workspace.recovery.restore.request": ["workspace.manage", "hub.execute"],
|
||||
"workspace.script.list.request": "workspace.read",
|
||||
"workspace.script.start.request": "workspace.write",
|
||||
"workspace.script.stop.request": "workspace.write",
|
||||
@@ -205,7 +205,7 @@ const INBOUND_PERMISSION = {
|
||||
|
||||
const OUTBOUND_PERMISSION = {
|
||||
activity_log: "workspace.read",
|
||||
"agent.config.apply.response": "workspace.write",
|
||||
"agent.config.apply.response": ["workspace.write", "hub.execute"],
|
||||
"agent.detach.response": "workspace.write",
|
||||
"agent.fork_context.response": "workspace.read",
|
||||
"agent.provider_subagents.list.response": "workspace.read",
|
||||
@@ -219,23 +219,23 @@ const OUTBOUND_PERMISSION = {
|
||||
"agent.skills.uninstall.response": "daemon.manage",
|
||||
"agent.timeline.list_prompts.response": "workspace.read",
|
||||
"agent.timeline.append.response": "workspace.write",
|
||||
"agent.timeline.replacement": "workspace.read",
|
||||
"agent.timeline.set_subscription.response": "workspace.read",
|
||||
agent_archived: "workspace.read",
|
||||
agent_attention_required: "workspace.read",
|
||||
agent_deleted: "workspace.read",
|
||||
agent_permission_request: "workspace.read",
|
||||
agent_permission_resolved: "workspace.write",
|
||||
agent_status: "workspace.read",
|
||||
agent_stream: "workspace.read",
|
||||
agent_update: "workspace.read",
|
||||
archive_workspace_response: "workspace.manage",
|
||||
"agent.timeline.replacement": ["workspace.read", "hub.execute"],
|
||||
"agent.timeline.set_subscription.response": ["workspace.read", "hub.execute"],
|
||||
agent_archived: ["workspace.read", "hub.execute"],
|
||||
agent_attention_required: ["workspace.read", "hub.execute"],
|
||||
agent_deleted: ["workspace.read", "hub.execute"],
|
||||
agent_permission_request: ["workspace.read", "hub.execute"],
|
||||
agent_permission_resolved: ["workspace.write", "hub.execute"],
|
||||
agent_status: ["workspace.read", "hub.execute"],
|
||||
agent_stream: ["workspace.read", "hub.execute"],
|
||||
agent_update: ["workspace.read", "hub.execute"],
|
||||
archive_workspace_response: ["workspace.manage", "hub.execute"],
|
||||
artifact: "workspace.read",
|
||||
assistant_chunk: "workspace.read",
|
||||
audio_output: "workspace.write",
|
||||
branch_suggestions_response: "workspace.read",
|
||||
"browser.automation.execute.request": "workspace.write",
|
||||
cancel_agent_response: "workspace.write",
|
||||
cancel_agent_response: ["workspace.write", "hub.execute"],
|
||||
capture_terminal_response: "workspace.read",
|
||||
"chat/create/response": "workspace.write",
|
||||
"chat/delete/response": "workspace.write",
|
||||
@@ -282,11 +282,11 @@ const OUTBOUND_PERMISSION = {
|
||||
dictation_stream_partial: "workspace.write",
|
||||
directory_suggestions_response: "workspace.read",
|
||||
fetch_agent_history_response: "workspace.read",
|
||||
fetch_agent_response: "workspace.read",
|
||||
fetch_agent_timeline_response: "workspace.read",
|
||||
fetch_agents_response: "workspace.read",
|
||||
fetch_agent_response: ["workspace.read", "hub.execute"],
|
||||
fetch_agent_timeline_response: ["workspace.read", "hub.execute"],
|
||||
fetch_agents_response: ["workspace.read", "hub.execute"],
|
||||
fetch_recent_provider_sessions_response: "workspace.read",
|
||||
fetch_workspaces_response: "workspace.read",
|
||||
fetch_workspaces_response: ["workspace.read", "hub.execute"],
|
||||
"file.upload.response": "workspace.write",
|
||||
file_download_token_response: "workspace.read",
|
||||
file_explorer_response: "workspace.read",
|
||||
@@ -370,7 +370,7 @@ const OUTBOUND_PERMISSION = {
|
||||
"schedule/run-once/response": "automation.manage",
|
||||
"schedule/update/response": "automation.manage",
|
||||
script_status_update: "workspace.read",
|
||||
send_agent_message_response: "workspace.write",
|
||||
send_agent_message_response: ["workspace.write", "hub.execute"],
|
||||
set_agent_feature_response: "workspace.write",
|
||||
set_agent_mode_response: "workspace.write",
|
||||
set_agent_model_response: "workspace.write",
|
||||
@@ -403,8 +403,8 @@ const OUTBOUND_PERMISSION = {
|
||||
"workspace.label.update": "workspace.read",
|
||||
"workspace.label.update.response": "workspace.manage",
|
||||
"workspace.pin.set.response": "workspace.manage",
|
||||
"workspace.recovery.inspect.response": "workspace.read",
|
||||
"workspace.recovery.restore.response": "workspace.manage",
|
||||
"workspace.recovery.inspect.response": ["workspace.read", "hub.execute"],
|
||||
"workspace.recovery.restore.response": ["workspace.manage", "hub.execute"],
|
||||
"workspace.script.list.response": "workspace.read",
|
||||
"workspace.script.start.response": "workspace.write",
|
||||
"workspace.script.stop.response": "workspace.write",
|
||||
@@ -412,7 +412,7 @@ const OUTBOUND_PERMISSION = {
|
||||
workspace_setup_progress: "workspace.read",
|
||||
workspace_setup_status_response: "workspace.read",
|
||||
"workspace.setup.run.response": "workspace.write",
|
||||
workspace_update: "workspace.read",
|
||||
workspace_update: ["workspace.read", "hub.execute"],
|
||||
write_project_config_response: "workspace.write",
|
||||
} as const satisfies Record<OutboundOperation, PermissionRequirement>;
|
||||
|
||||
@@ -420,6 +420,14 @@ export function requiredPermissionForInbound(operation: InboundOperation): Permi
|
||||
return INBOUND_PERMISSION[operation];
|
||||
}
|
||||
|
||||
export function requiredPermissionForOutbound(operation: OutboundOperation): PermissionRequirement {
|
||||
return OUTBOUND_PERMISSION[operation];
|
||||
export function requiredPermissionForOutbound(
|
||||
message: SessionOutboundMessage,
|
||||
): PermissionRequirement {
|
||||
// The legacy status envelope carries both agent RPC results and daemon configuration.
|
||||
if (
|
||||
message.type === "status" &&
|
||||
(message.payload.status === "agent_created" || message.payload.status === "agent_create_failed")
|
||||
)
|
||||
return ["workspace.write", "hub.execute"];
|
||||
return OUTBOUND_PERMISSION[message.type];
|
||||
}
|
||||
|
||||
@@ -149,7 +149,13 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
this.host.metrics.nativeRenameEventCount += 1;
|
||||
const knownDirectory = this.directories.has(path);
|
||||
this.classify(path, (isDirectory) => {
|
||||
if (!isDirectory) return;
|
||||
if (!isDirectory) {
|
||||
// Remember files as soon as we announce them. A coalesced delete must
|
||||
// still be found by the next audit, even before the first directory scan.
|
||||
this.files.add(path);
|
||||
this.entries.get(scope)?.files.add(path);
|
||||
return;
|
||||
}
|
||||
if (knownDirectory) this.requestAudit(path, true);
|
||||
else this.requestAudit(scope);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { expect, test } from "vitest";
|
||||
import type { FileChange } from "./index.js";
|
||||
import { createNativeRecursiveBackend } from "./internal/native-recursive.js";
|
||||
import { createObserverPaths } from "./internal/paths.js";
|
||||
|
||||
test("the recursive watcher remembers newly announced files before reconciliation", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paseo-observer-native-"));
|
||||
const paths = createObserverPaths(process.platform);
|
||||
const changes: FileChange[] = [];
|
||||
let active = true;
|
||||
const backend = createNativeRecursiveBackend(
|
||||
{
|
||||
root,
|
||||
metrics: {
|
||||
reconciliationCount: 0,
|
||||
scopedReconciliationCount: 0,
|
||||
fullReconciliationCount: 0,
|
||||
reconciliationFailureCount: 0,
|
||||
observerFailureCount: 0,
|
||||
directoryLimitFailureCount: 0,
|
||||
nativeEventCount: 0,
|
||||
nativeChangeEventCount: 0,
|
||||
nativeRenameEventCount: 0,
|
||||
nativePathlessEventCount: 0,
|
||||
nativeClassificationCount: 0,
|
||||
nativeShallowScanCount: 0,
|
||||
lastReconciliationDurationMs: 0,
|
||||
maxReconciliationDurationMs: 0,
|
||||
},
|
||||
isActive: () => active,
|
||||
isIgnored: () => false,
|
||||
isPathInside: paths.isInside,
|
||||
queueEvent: (type, path) => {
|
||||
changes.push({ type, path });
|
||||
},
|
||||
fail: (error) => {
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
paths,
|
||||
);
|
||||
try {
|
||||
await backend.start();
|
||||
const path = join(root, "new.txt");
|
||||
await writeFile(path, "created");
|
||||
await expect
|
||||
.poll(() => changes.some((event) => event.path === path && event.type === "create"))
|
||||
.toBe(true);
|
||||
// Consumers have seen this file. A later audit needs it in its comparison
|
||||
// inventory even if the native watcher coalesces the subsequent delete.
|
||||
expect(backend.getDiagnostics().nativeTrackedFileCount).toBe(1);
|
||||
await rm(path);
|
||||
await backend.updateIgnore();
|
||||
await expect
|
||||
.poll(() => changes.some((event) => event.path === path && event.type === "delete"))
|
||||
.toBe(true);
|
||||
} finally {
|
||||
active = false;
|
||||
await backend.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -35,30 +35,148 @@ test("Hub retries one durable daemon execution across concurrency and reconstruc
|
||||
expect(reconstructed.durableAgentCount).toBe(1);
|
||||
});
|
||||
|
||||
test("Hub session applies its scoped authorization to ordinary protocol messages", async () => {
|
||||
test("Hub execute can steer ordinary agents while unrelated administration stays denied", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const localAgentId = await hub.createUnrelatedLocalAgent();
|
||||
|
||||
const steeringDenial = await hub.deniedSteering(localAgentId);
|
||||
const browserDenial = await hub.deniedBrowserDispatch();
|
||||
|
||||
expect(steeringDenial).toEqual({
|
||||
requestId: "denied-steer",
|
||||
requestType: "send_agent_message_request",
|
||||
error: "Session is not authorized for send_agent_message_request",
|
||||
code: "access_denied",
|
||||
const agentId = await hub.createUnrelatedLocalAgent();
|
||||
const response = await hub.requestOrdinary({
|
||||
type: "send_agent_message_request",
|
||||
requestId: "ordinary-steer",
|
||||
agentId,
|
||||
text: "hello",
|
||||
messageId: "first-arrival",
|
||||
activeTurnBehavior: "steer",
|
||||
});
|
||||
expect(browserDenial).toEqual({
|
||||
requestId: "browser-1",
|
||||
requestType: "browser.automation.execute.response",
|
||||
error: "Session is not authorized for browser.automation.execute.response",
|
||||
code: "access_denied",
|
||||
expect(response).toMatchObject({
|
||||
type: "send_agent_message_response",
|
||||
payload: { accepted: true },
|
||||
});
|
||||
expect(hub.observedAgentIds()).not.toContain(localAgentId);
|
||||
expect(hub.observedTrustedLifecycleMessages()).toEqual(["server_info"]);
|
||||
expect(await hub.deniedBrowserDispatch()).toMatchObject({ code: "access_denied" });
|
||||
expect(hub.serverInfoPermissions()).toEqual([["hub.execute"]]);
|
||||
});
|
||||
|
||||
test("ordinary Hub create and message retries do not duplicate agents or prompts", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const create = {
|
||||
type: "create_agent_request",
|
||||
idempotencyKey: "ordinary-create",
|
||||
config: { provider: "codex", cwd: hub.repoRoot() },
|
||||
};
|
||||
const responses = await Promise.all([
|
||||
hub.requestOrdinary({ ...create, requestId: "create-first" }),
|
||||
hub.requestOrdinary({ ...create, requestId: "create-duplicate" }),
|
||||
]);
|
||||
const first = responses[0];
|
||||
expect(first).toMatchObject({ type: "status", payload: { status: "agent_created" } });
|
||||
if (first?.type !== "status" || first.payload.status !== "agent_created")
|
||||
throw new Error("Agent was not created");
|
||||
const agentId = first.payload.agentId;
|
||||
expect(responses[1]).toMatchObject({ type: "status", payload: { agentId } });
|
||||
expect(
|
||||
await hub.requestOrdinary({
|
||||
type: "fetch_agents_request",
|
||||
requestId: "observe-agents",
|
||||
subscribe: { subscriptionId: "hub-agents" },
|
||||
}),
|
||||
).toMatchObject({ type: "fetch_agents_response" });
|
||||
expect(
|
||||
await hub.requestOrdinary({
|
||||
type: "agent.timeline.set_subscription.request",
|
||||
requestId: "observe-timeline",
|
||||
agentIds: [agentId],
|
||||
}),
|
||||
).toMatchObject({ type: "agent.timeline.set_subscription.response" });
|
||||
const message = {
|
||||
type: "send_agent_message_request",
|
||||
agentId,
|
||||
messageId: "ordinary-arrival",
|
||||
text: "hello",
|
||||
activeTurnBehavior: "steer",
|
||||
};
|
||||
expect(await hub.requestOrdinary({ ...message, requestId: "message-first" })).toMatchObject({
|
||||
payload: { accepted: true },
|
||||
});
|
||||
expect(await hub.requestOrdinary({ ...message, requestId: "message-duplicate" })).toMatchObject({
|
||||
payload: { accepted: true },
|
||||
});
|
||||
expect(
|
||||
hub
|
||||
.hubMessages()
|
||||
.some((event) => event.type === "agent_stream" && event.payload.agentId === agentId),
|
||||
).toBe(true);
|
||||
expect(hub.providerPromptTexts().filter((text) => text === "hello")).toHaveLength(1);
|
||||
expect(
|
||||
await hub.requestOrdinary({ ...message, text: "changed", requestId: "message-conflict" }),
|
||||
).toMatchObject({ payload: { accepted: false } });
|
||||
});
|
||||
|
||||
test("ordinary Hub requests survive daemon restart and restore an archived workspace", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const create = {
|
||||
type: "create_agent_request",
|
||||
idempotencyKey: "restorable-agent",
|
||||
config: { provider: "codex", cwd: hub.repoRoot() },
|
||||
worktree: { mode: "branch-off", newBranch: "ordinary-restoration" },
|
||||
};
|
||||
const response = await hub.requestOrdinary({ ...create, requestId: "restorable-create" });
|
||||
if (response.type !== "status" || response.payload.status !== "agent_created")
|
||||
throw new Error("Agent was not created");
|
||||
const { agentId, agent } = response.payload;
|
||||
if (!agent?.workspaceId) throw new Error("Workspace was not created");
|
||||
const workspaceId = agent.workspaceId;
|
||||
const message = {
|
||||
type: "send_agent_message_request",
|
||||
agentId,
|
||||
messageId: "before-restart",
|
||||
text: "hello",
|
||||
activeTurnBehavior: "steer",
|
||||
};
|
||||
expect(await hub.requestOrdinary({ ...message, requestId: "before-restart-send" })).toMatchObject(
|
||||
{ payload: { accepted: true } },
|
||||
);
|
||||
await hub.restartDaemon();
|
||||
await hub.socketDialed();
|
||||
hub.connectLatestSocket();
|
||||
expect(await hub.requestOrdinary({ ...create, requestId: "replayed-create" })).toMatchObject({
|
||||
payload: { agentId },
|
||||
});
|
||||
expect(await hub.requestOrdinary({ ...message, requestId: "replayed-send" })).toMatchObject({
|
||||
payload: { accepted: true },
|
||||
});
|
||||
expect(hub.providerPromptTexts().filter((text) => text === "hello")).toHaveLength(1);
|
||||
expect(
|
||||
await hub.requestOrdinary({
|
||||
type: "archive_workspace_request",
|
||||
workspaceId,
|
||||
requestId: "ordinary-archive",
|
||||
}),
|
||||
).toMatchObject({ payload: { error: null, archivedAt: expect.any(String) } });
|
||||
expect((await hub.worktreeState(agent.cwd)).exists).toBe(false);
|
||||
expect(
|
||||
await hub.requestOrdinary({
|
||||
type: "workspace.recovery.inspect.request",
|
||||
workspaceId,
|
||||
requestId: "inspect-recovery",
|
||||
}),
|
||||
).toMatchObject({ payload: { state: { kind: "recoverable" } } });
|
||||
expect(
|
||||
await hub.requestOrdinary({
|
||||
type: "workspace.recovery.restore.request",
|
||||
workspaceId,
|
||||
requestId: "restore-workspace",
|
||||
}),
|
||||
).toMatchObject({ payload: { accepted: true } });
|
||||
expect((await hub.worktreeState(agent.cwd)).exists).toBe(true);
|
||||
expect(
|
||||
await hub.requestOrdinary({
|
||||
...message,
|
||||
text: "follow up",
|
||||
messageId: "after-restore",
|
||||
requestId: "after-restore-send",
|
||||
}),
|
||||
).toMatchObject({ payload: { agentId, accepted: true } });
|
||||
expect(hub.providerPromptTexts().filter((text) => text === "follow up")).toHaveLength(1);
|
||||
}, 20_000);
|
||||
|
||||
test("Hub completes the standard hello before rejecting a second hello", async () => {
|
||||
const hub = await launchRelationship();
|
||||
|
||||
|
||||
@@ -160,7 +160,9 @@ class MemoryHubSocket extends EventEmitter implements WebSocketLike, HubSocketCo
|
||||
if (typeof data !== "string") return;
|
||||
const frame = JSON.parse(data) as { type: "session"; message: SessionOutboundMessage };
|
||||
this.sent.push(frame.message);
|
||||
this.messageObserved.resolve();
|
||||
const observed = this.messageObserved;
|
||||
this.messageObserved = deferred<void>();
|
||||
observed.resolve();
|
||||
}
|
||||
|
||||
close(code = 1000): void {
|
||||
@@ -192,7 +194,6 @@ class MemoryHubSocket extends EventEmitter implements WebSocketLike, HubSocketCo
|
||||
);
|
||||
if (message) return message;
|
||||
await this.messageObserved.promise;
|
||||
this.messageObserved = deferred<void>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +204,6 @@ class MemoryHubSocket extends EventEmitter implements WebSocketLike, HubSocketCo
|
||||
const message = this.sent.find(predicate);
|
||||
if (message) return message;
|
||||
await this.messageObserved.promise;
|
||||
this.messageObserved = deferred<void>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -728,6 +728,16 @@ export class HubRelationshipHarness {
|
||||
socket.socket.close(code);
|
||||
}
|
||||
|
||||
async requestOrdinary(message: {
|
||||
type: string;
|
||||
requestId: string;
|
||||
[key: string]: unknown;
|
||||
}): Promise<SessionOutboundMessage> {
|
||||
const socket = this.latestSocket().socket;
|
||||
socket.receive(message);
|
||||
return socket.messageFor(message.requestId);
|
||||
}
|
||||
|
||||
sendHubRequestOnLatest(message: unknown): SessionOutboundMessage[] {
|
||||
const socket = this.latestSocket().socket;
|
||||
socket.receive(message);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
|
||||
import { execSync } from "child_process";
|
||||
import { existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
@@ -361,6 +362,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
const messages = options.messages ?? [];
|
||||
|
||||
const sessionOptions: SessionOptions = {
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
clientId: options.clientId ?? "test-client",
|
||||
onMessage: (message) => messages.push(message),
|
||||
...(options.targetedMessages
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentRequests } from "./agent/requests/index.js";
|
||||
import equal from "fast-deep-equal";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
|
||||
@@ -461,6 +462,7 @@ export interface SessionOptions {
|
||||
worktreesRoot?: string;
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
agentRequests: Pick<AgentRequests, "create" | "send">;
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
directorySync?: DirectorySyncService;
|
||||
@@ -745,6 +747,7 @@ export class Session {
|
||||
private readonly daemonSession: DaemonSession;
|
||||
private readonly hubExecutionController: HubExecutionController | null;
|
||||
private readonly workspaceScripts: WorkspaceScriptsService;
|
||||
private readonly agentRequests: Pick<AgentRequests, "create" | "send">;
|
||||
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
|
||||
|
||||
constructor(options: SessionOptions) {
|
||||
@@ -818,6 +821,7 @@ export class Session {
|
||||
this.onWorkspaceRecovered = onWorkspaceRecovered ?? null;
|
||||
this.pushNotifications = pushNotifications;
|
||||
this.paseoHome = paseoHome;
|
||||
this.agentRequests = options.agentRequests;
|
||||
this.projectIcons = new ProjectIconReader(paseoHome);
|
||||
this.worktreesRoot = worktreesRoot;
|
||||
this.pluginRuntime = pluginRuntime;
|
||||
@@ -1834,7 +1838,7 @@ export class Session {
|
||||
}
|
||||
|
||||
private isProviderVisibleToClient(provider: string): boolean {
|
||||
if (clientSupportsAllProviders(this.appVersion)) {
|
||||
if (this.supports(CLIENT_CAPS.allProviders) || clientSupportsAllProviders(this.appVersion)) {
|
||||
return true;
|
||||
}
|
||||
return LEGACY_PROVIDER_IDS.has(provider);
|
||||
@@ -3525,10 +3529,69 @@ export class Session {
|
||||
* Handle create agent request
|
||||
*/
|
||||
private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise<void> {
|
||||
try {
|
||||
let agent: AgentSnapshotPayload;
|
||||
if (msg.idempotencyKey !== undefined) {
|
||||
if (msg.initialPrompt !== undefined) {
|
||||
throw new Error("Idempotent creation requires sending the initial prompt separately");
|
||||
}
|
||||
const { requestId: _requestId, idempotencyKey, ...request } = msg;
|
||||
const id = await this.agentRequests.create({
|
||||
key: idempotencyKey,
|
||||
request,
|
||||
findAgent: async (agentId) =>
|
||||
this.agentManager.getAgent(agentId) != null ||
|
||||
(await this.agentStorage.get(agentId)) !== null,
|
||||
create: async (agentId) => {
|
||||
await this.createSessionAgent(msg, agentId);
|
||||
},
|
||||
});
|
||||
const record = await this.agentStorage.get(id);
|
||||
if (!record) throw new Error("Previously created agent no longer exists");
|
||||
agent = this.buildStoredAgentPayload(record);
|
||||
} else {
|
||||
agent = await this.createSessionAgent(msg);
|
||||
}
|
||||
this.emit({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_created",
|
||||
agentId: agent.id,
|
||||
requestId: msg.requestId,
|
||||
agent,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const wireError = toWorktreeWireError(error);
|
||||
this.sessionLogger.error({ err: error }, "Failed to create agent");
|
||||
this.emit({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_create_failed",
|
||||
requestId: msg.requestId,
|
||||
error: wireError.message,
|
||||
errorCode: wireError.code,
|
||||
},
|
||||
});
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "error",
|
||||
content: `Failed to create agent: ${wireError.message}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async createSessionAgent(
|
||||
msg: CreateAgentRequestMessage,
|
||||
agentId?: string,
|
||||
): Promise<AgentSnapshotPayload> {
|
||||
const {
|
||||
config,
|
||||
worktreeName,
|
||||
requestId,
|
||||
initialPrompt,
|
||||
clientMessageId,
|
||||
outputSchema,
|
||||
@@ -3594,6 +3657,7 @@ export class Session {
|
||||
},
|
||||
{
|
||||
kind: "session",
|
||||
agentId,
|
||||
config: resolvedIntent.config,
|
||||
workspaceId: resolvedIntent.intent.workspaceId,
|
||||
worktreeName,
|
||||
@@ -3628,50 +3692,17 @@ export class Session {
|
||||
agentId: snapshot.id,
|
||||
createdWorktree,
|
||||
});
|
||||
if (requestId) {
|
||||
const agentPayload = await this.buildAgentPayload(liveSnapshot);
|
||||
this.emit({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_created",
|
||||
agentId: liveSnapshot.id,
|
||||
requestId,
|
||||
agent: agentPayload,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.sessionLogger.info(
|
||||
{ agentId: snapshot.id, provider: snapshot.provider },
|
||||
`Created agent ${snapshot.id} (${snapshot.provider})`,
|
||||
"Created agent",
|
||||
);
|
||||
return this.buildAgentPayload(liveSnapshot);
|
||||
} catch (error) {
|
||||
await this.createAgentLifecycleDispatch.cleanupCreatedWorktreeAfterFailedAgentCreate({
|
||||
createdWorktree: createdWorktreeForCleanup,
|
||||
createdAgentId,
|
||||
});
|
||||
const wireError = toWorktreeWireError(error);
|
||||
this.sessionLogger.error({ err: error }, "Failed to create agent");
|
||||
if (requestId) {
|
||||
this.emit({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_create_failed",
|
||||
requestId,
|
||||
error: wireError.message,
|
||||
errorCode: wireError.code,
|
||||
},
|
||||
});
|
||||
}
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "error",
|
||||
content: `Failed to create agent: ${wireError.message}`,
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7465,9 +7496,8 @@ export class Session {
|
||||
},
|
||||
"agent.session.send_agent_message",
|
||||
);
|
||||
let dispatchResult: { disposition: "out_of_band" | "steered" | "turn_started" };
|
||||
try {
|
||||
dispatchResult = await sendPromptToAgent({
|
||||
const send = async () => {
|
||||
const result = await sendPromptToAgent({
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
agentId,
|
||||
@@ -7477,47 +7507,26 @@ export class Session {
|
||||
clearPendingPermissions: true,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.handleAgentRunError(agentId, error, "Failed to send agent message");
|
||||
this.emit({
|
||||
type: "send_agent_message_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
agentId,
|
||||
accepted: false,
|
||||
error: message,
|
||||
if (result.disposition === "turn_started") {
|
||||
await waitForAgentRunStartWithTimeout(this.agentManager, agentId);
|
||||
}
|
||||
};
|
||||
if (msg.messageId) {
|
||||
await this.agentRequests.send({
|
||||
agentId,
|
||||
messageId: msg.messageId,
|
||||
request: { prompt, activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt" },
|
||||
prepare: async () => {
|
||||
await ensureAgentLoaded(agentId, {
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
},
|
||||
send,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (dispatchResult.disposition !== "turn_started") {
|
||||
this.emit({
|
||||
type: "send_agent_message_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
agentId,
|
||||
accepted: true,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForAgentRunStartWithTimeout(this.agentManager, agentId);
|
||||
} catch (error) {
|
||||
this.emit({
|
||||
type: "send_agent_message_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
agentId,
|
||||
accepted: false,
|
||||
error: errorToFriendlyMessage(error),
|
||||
},
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
await send();
|
||||
}
|
||||
|
||||
this.emit({
|
||||
@@ -7530,6 +7539,7 @@ export class Session {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.handleAgentRunError(resolved.agentId, error, "Failed to send agent message");
|
||||
this.emit({
|
||||
type: "send_agent_message_response",
|
||||
payload: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import path from "node:path";
|
||||
import type pino from "pino";
|
||||
@@ -191,6 +192,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
|
||||
};
|
||||
|
||||
const session = new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
clientId: "test-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
onMessage: (message) => emitted.push(message as { type: string; payload: unknown }),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
|
||||
// Invariant tests for cwd → workspace resolution on the open_project_request path.
|
||||
// Each test encodes a default behavior we want from `findOrCreateWorkspaceForDirectory`.
|
||||
// Run to see which invariants the current code already satisfies (green) and which
|
||||
@@ -88,6 +89,7 @@ function createHarness(input: {
|
||||
};
|
||||
|
||||
const session = new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
clientId: "test",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
appVersion: null,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
@@ -636,6 +637,7 @@ function createSessionForWorkspaceTests(
|
||||
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
clientId: "test-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
appVersion: options.appVersion ?? null,
|
||||
@@ -992,6 +994,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
clientId: "test-client",
|
||||
serverId: "test-server",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
@@ -1145,6 +1148,7 @@ test("create_agent_request launches from an exact subdirectory in a created work
|
||||
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
clientId: "test-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
appVersion: null,
|
||||
@@ -1282,6 +1286,7 @@ test("create_agent_request does not title an existing workspace from the agent p
|
||||
let generateCalls = 0;
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
clientId: "test-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
appVersion: null,
|
||||
@@ -1551,6 +1556,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
|
||||
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
clientId: "test-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
onMessage: (message) => emitted.push(message),
|
||||
@@ -1916,6 +1922,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy
|
||||
const cancelAgentRun = vi.fn(async () => ({ status: "settled" as const }));
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
clientId: "test-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
onMessage: (message) => emitted.push(message),
|
||||
@@ -2084,6 +2091,7 @@ test("close_items_request archives stored agents that are not currently loaded",
|
||||
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
clientId: "test-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
onMessage: (message) => emitted.push(message),
|
||||
@@ -2243,6 +2251,7 @@ test("close_items_request continues after an archive failure", async () => {
|
||||
const killTerminalBestEffort = vi.fn();
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
clientId: "test-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
onMessage: (message) => emitted.push(message),
|
||||
@@ -3514,6 +3523,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
|
||||
|
||||
const session = asTestSession(
|
||||
new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
clientId: "test-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
onMessage: (message) => emitted.push(message),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
@@ -99,6 +100,7 @@ describe("snapshot mutation ownership boundary", () => {
|
||||
|
||||
const session = asInternals<SessionInternals>(
|
||||
new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
clientId: "test-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
onMessage,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
@@ -263,3 +264,14 @@ export function createProviderSnapshotManagerStub(): {
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
|
||||
export function createAgentRequestsStub(): SessionOptions["agentRequests"] {
|
||||
return {
|
||||
async create(input) {
|
||||
const agentId = randomUUID();
|
||||
await input.create(agentId);
|
||||
return agentId;
|
||||
},
|
||||
send: (input) => input.send(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentRequests } from "./agent/requests/index.js";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import type { IncomingMessage, Server as HTTPServer } from "http";
|
||||
import { join } from "path";
|
||||
@@ -546,6 +547,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined;
|
||||
private readonly agentManager: AgentManager;
|
||||
private readonly agentStorage: AgentStorage;
|
||||
private readonly agentRequests: AgentRequests;
|
||||
private readonly projectRegistry: ProjectRegistry;
|
||||
private readonly workspaceRegistry: WorkspaceRegistry;
|
||||
private readonly workspaceLabelService: WorkspaceLabelService | null;
|
||||
@@ -666,6 +668,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.orchestrationSkills = orchestrationSkills;
|
||||
this.agentManager = agentManager;
|
||||
this.agentStorage = agentStorage;
|
||||
this.agentRequests = new AgentRequests(join(paseoHome, "agent-requests"));
|
||||
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
|
||||
this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry();
|
||||
this.workspaceLabelService = workspaceLabelService ?? null;
|
||||
@@ -1409,6 +1412,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
agentRequests: this.agentRequests,
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
workspaceLabelService: this.workspaceLabelService ?? undefined,
|
||||
@@ -1628,6 +1632,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true,
|
||||
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
|
||||
features: {
|
||||
agentRequestReceipts: true,
|
||||
hubAgentRpc: true,
|
||||
// COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12.
|
||||
directorySync: true,
|
||||
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAgentRequestsStub } from "./test-utils/session-stubs.js";
|
||||
import pino from "pino";
|
||||
import { z } from "zod";
|
||||
import { describe, expect, test } from "vitest";
|
||||
@@ -206,6 +207,7 @@ function createSessionForWireCompatTest(options?: {
|
||||
];
|
||||
|
||||
const session = new Session({
|
||||
agentRequests: createAgentRequestsStub(),
|
||||
clientId: "wire-compat-client",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
clientCapabilities: options?.clientCapabilities ?? null,
|
||||
|
||||
Reference in New Issue
Block a user