Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion src/handlers/project/invoke/invoke.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,31 @@ import type {
GetAgentRuntimeResponse,
GetHarnessResponse,
} from "@aws-sdk/client-bedrock-agentcore-control";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ProjectSpecSchema } from "../../../projectSchemas/project";
import { ProjectKey } from "../../../router";
import { cleanupScreens, renderScreen, TestCoreClient, waitForText } from "../../../testing";
import {
cleanupScreens,
flatFrame,
renderScreen,
TestCoreClient,
waitForFlatText,
waitForText,
} from "../../../testing";
import type { Project, ResolvedDeployedResource } from "../types";

const originalCwd = process.cwd();
const tempDirectories: string[] = [];

afterEach(cleanupScreens);
afterEach(async () => {
process.chdir(originalCwd);
await Promise.all(
tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});

const project: Project = {
name: "orders",
Expand Down Expand Up @@ -114,6 +133,22 @@ describe("project invoke picker", () => {
await waitForText(screen.lastFrame, "manage an AgentCore project");
});

test("reports the CLI's own guidance outside a project", async () => {
const directory = await mkdtemp(join(tmpdir(), "agentcore-no-project-"));
tempDirectories.push(directory);
process.chdir(directory);
const screen = renderScreen("/agentcore/project/invoke", { core: core() });

await waitForFlatText(screen.lastFrame, "No AgentCore project found");
const frame = flatFrame(screen.lastFrame);
expect(frame).toContain(directory);
expect(frame).toContain("agentcore project create");
expect(frame).not.toContain("Resolving project");
// esc is a way off the error, not just ctl+c.
await screen.press("escape");
await waitForText(screen.lastFrame, "manage an AgentCore project");
});

test("resolves the enclosing project when opened from the project menu", async () => {
const value = core();
value.projectManager.resolve = async () => project;
Expand Down
66 changes: 44 additions & 22 deletions src/handlers/project/invoke/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { HarnessChat } from "../../harness/invoke/screen";
import { RegionKey } from "../../keys";
import { RuntimeInvokeConsole } from "../../runtime/invoke/screen";
import type { ScreenProps } from "../../types";
import type { ResolvedDeployedResources } from "../types";
import { useProject } from "../ProjectGate";
import type { Project, ResolvedDeployedResources } from "../types";
import { ProjectGate } from "../ProjectGate";

type ProjectInvokableRow = Record<string, unknown> & {
resourceType: "runtime" | "harness";
Expand All @@ -33,26 +33,48 @@ type Destination =
| { resourceType: "runtime"; id: string; ctx: Context; qualifier?: string }
| { resourceType: "harness"; id: string; ctx: Context };

const BREADCRUMB = ["agentcore", "project", "invoke"];
const PROJECT_MENU = "/agentcore/project";

// The project comes from the launch context when a project command opened the
// TUI, and is resolved from the cwd otherwise — the gate reports the CLI's own
// not-found guidance when there is none, rather than spinning forever.
export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) {
const navigate = useNavigate();
// The project comes from the launch context when a project command opened
// the TUI, and is resolved from the cwd otherwise.
const { data: project, error: projectError } = useProject(core, ctx.value(ProjectKey));
return (
<ProjectGate
core={core}
breadcrumb={BREADCRUMB}
description="invoke a Runtime or Harness from the current project"
seed={ctx.value(ProjectKey)}
onBack={() => navigate(PROJECT_MENU)}
>
{(project) => <ProjectInvokePicker ctx={ctx} core={core} project={project} />}
</ProjectGate>
);
}

function ProjectInvokePicker({
ctx,
core,
project,
}: ScreenProps & {
project: Project;
}) {
const navigate = useNavigate();
const [deployed, setDeployed] = useState<ResolvedDeployedResources>();
const [destination, setDestination] = useState<Destination>();
const [deployedError, setDeployedError] = useState<string>();
const error = projectError?.message ?? deployedError;
const [error, setError] = useState<string>();

useEffect(() => {
if (!project) return;
let active = true;
void core.projectManager
.resolveDeployedResources(project, { target: "default" })
.then((resolved) => {
if (active) setDeployed(resolved);
})
.catch((cause: unknown) => {
if (active) setDeployedError(cause instanceof Error ? cause.message : String(cause));
if (active) setError(cause instanceof Error ? cause.message : String(cause));
});
return () => {
active = false;
Expand All @@ -63,15 +85,15 @@ export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) {
() =>
(deployed?.resources ?? []).map((resource) => {
if (resource.resourceType === "runtime") {
const configured = project?.spec.runtimes.find(({ name }) => name === resource.name);
const configured = project.spec.runtimes.find(({ name }) => name === resource.name);
return {
...resource,
type: "Runtime" as const,
protocol: configured?.protocol ?? "HTTP",
source: configured?.codeLocation ?? "-",
};
}
const configured = project?.spec.harnesses.find(({ name }) => name === resource.name);
const configured = project.spec.harnesses.find(({ name }) => name === resource.name);
return {
...resource,
type: "Harness" as const,
Expand All @@ -91,9 +113,9 @@ export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) {
});
};

const goBack = () => navigate("/agentcore/project");
const goBack = () => navigate(PROJECT_MENU);
useInput((_input, key) => {
if (key.escape && (!project || !deployed || error !== undefined)) goBack();
if (key.escape && (!deployed || error !== undefined)) goBack();
});

if (destination?.resourceType === "runtime") {
Expand Down Expand Up @@ -133,39 +155,39 @@ export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) {
);
}

if (!project || (!deployed && !error)) {
if (error !== undefined) {
return (
<Layout
breadcrumb={["agentcore", "project", "invoke"]}
description={project ? "resolving deployed resources" : "resolving the current project"}
breadcrumb={BREADCRUMB}
description="unable to load deployed resources"
keyHints={[
{ key: "esc", label: "back" },
{ key: "ctl+c", label: "quit" },
]}
>
<Spinner label={project ? "Resolving deployed resources…" : "Resolving project…"} />
<Text color="red">✗ {error}</Text>
</Layout>
);
}

if (error) {
if (!deployed) {
return (
<Layout
breadcrumb={["agentcore", "project", "invoke"]}
description="unable to load deployed resources"
breadcrumb={BREADCRUMB}
description="resolving deployed resources"
keyHints={[
{ key: "esc", label: "back" },
{ key: "ctl+c", label: "quit" },
]}
>
<Text color="red">{error}</Text>
<Spinner label="Resolving deployed resources…" />
</Layout>
);
}

return (
<Layout
breadcrumb={["agentcore", "project", "invoke"]}
breadcrumb={BREADCRUMB}
description="choose a project resource to invoke on target default"
keyHints={[
{ key: "↑↓/jk", label: "navigate" },
Expand Down
Loading