diff --git a/packages/cli/src/commands/agent/run.test.ts b/packages/cli/src/commands/agent/run.test.ts index 8f707c5e0c..b4e3b20bde 100644 --- a/packages/cli/src/commands/agent/run.test.ts +++ b/packages/cli/src/commands/agent/run.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; import { resolveExistingRunWorkspace, resolveRunCallerAgentId, @@ -8,13 +9,51 @@ import { const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; +// Answers fetchAgent the way a daemon does: an unknown id is an error. +function daemonWithAgents(...agentIds: string[]) { + return { + async fetchAgent({ agentId }: { agentId: string }) { + if (!agentIds.includes(agentId)) { + throw new Error(`Agent not found: ${agentId}`); + } + return { agent: { id: agentId } }; + }, + }; +} + describe("managed agent caller context", () => { - it("propagates a trimmed PASEO_AGENT_ID", () => { - expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " parent-agent " })).toBe("parent-agent"); + it("uses a trimmed PASEO_AGENT_ID when the target daemon runs that agent", async () => { + await expect( + resolveRunCallerAgentId(daemonWithAgents("parent-agent"), { + PASEO_AGENT_ID: " parent-agent ", + }), + ).resolves.toBe("parent-agent"); }); - it("omits blank caller ids", () => { - expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " " })).toBeUndefined(); + it("runs without a caller when PASEO_AGENT_ID belongs to another daemon", async () => { + await expect( + resolveRunCallerAgentId(daemonWithAgents("other-agent"), { + PASEO_AGENT_ID: "parent-agent", + }), + ).resolves.toBeUndefined(); + }); + + it("fails instead of dropping the caller when the lookup loses its connection", async () => { + const disconnectedDaemon = { + async fetchAgent(): Promise { + throw new DaemonConnectionError("Connection lost before message could be sent"); + }, + }; + + await expect( + resolveRunCallerAgentId(disconnectedDaemon, { PASEO_AGENT_ID: "parent-agent" }), + ).rejects.toBeInstanceOf(DaemonConnectionError); + }); + + it("omits blank caller ids", async () => { + await expect( + resolveRunCallerAgentId(daemonWithAgents(), { PASEO_AGENT_ID: " " }), + ).resolves.toBeUndefined(); }); }); diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index a8b140de1e..82817684c2 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -4,6 +4,7 @@ import { StructuredAgentResponseError, } from "@getpaseo/server/agent-response"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, @@ -526,7 +527,7 @@ export async function resolveExistingRunWorkspace( // Workspace policy for `paseo run`. Precedence: // 1. --workspace -> run in that existing workspace -// 2. $PASEO_AGENT_ID -> daemon resolves the caller's workspace +// 2. caller agent -> daemon resolves the caller's workspace // 3. $PASEO_WORKSPACE_ID -> exported by workspace terminals // 4. --new-workspace -> mint a new workspace explicitly // 5. bare run -> mint a new local-backed workspace for cwd @@ -534,6 +535,7 @@ async function resolveRunWorkspace( client: ConnectedDaemonClient, options: AgentRunOptions, cwd: string, + callerAgentId: string | undefined, ): Promise { const newWorkspace = resolveNewWorkspaceKind(options); const explicit = newWorkspace ? undefined : options.workspace?.trim(); @@ -542,7 +544,7 @@ async function resolveRunWorkspace( return resolveExistingRunWorkspace(client, explicit); } - if (!newWorkspace && resolveRunCallerAgentId()) { + if (!newWorkspace && callerAgentId) { return { cwd }; } @@ -609,9 +611,9 @@ export async function runRunCommand( const env = parseRunEnv(options.env); const requestEnv = Object.keys(env).length > 0 ? env : undefined; - const workspace = await resolveRunWorkspace(client, options, cwd); + const callerAgentId = await resolveRunCallerAgentId(client); + const workspace = await resolveRunWorkspace(client, options, cwd, callerAgentId); const workspaceId = workspace.id; - const callerAgentId = resolveRunCallerAgentId(); const runCwd = workspace.cwd; if (outputSchema) { @@ -743,8 +745,28 @@ export async function runRunCommand( } } -export function resolveRunCallerAgentId( +export interface RunCallerLookupClient { + fetchAgent(options: { agentId: string }): Promise<{ agent: { id: string } } | null>; +} + +// PASEO_AGENT_ID names an agent on the daemon that launched this shell. A run +// sent to another daemon (--host or --home) has no caller there, so it runs as +// a top-level agent instead of failing on an unknown caller. +export async function resolveRunCallerAgentId( + client: RunCallerLookupClient, env: { PASEO_AGENT_ID?: string } = process.env, -): string | undefined { - return env.PASEO_AGENT_ID?.trim() || undefined; +): Promise { + const agentId = env.PASEO_AGENT_ID?.trim(); + if (!agentId) { + return undefined; + } + const caller = await client.fetchAgent({ agentId }).catch((error: unknown) => { + // A daemon without this agent answers with an error. A lost or timed-out + // connection is not an answer, so it must not drop the caller. + if (error instanceof DaemonConnectionError) { + throw error; + } + return null; + }); + return caller?.agent.id === agentId ? agentId : undefined; }