Skip to content
Draft
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
46 changes: 32 additions & 14 deletions apps/aevatar-console-web/src/pages/studio/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3765,13 +3765,11 @@ describe("StudioPage", () => {
});
expect(
screen.getByText(
"Script starts as a named draft. It becomes a callable member only after Save script is catalog-applied and Bind succeeds.",
"Script creates a backend member and opens a stable script draft identity in Build. It becomes callable after Save script is catalog-applied and Bind succeeds.",
),
).toBeTruthy();
expect(screen.getByText(/Script id: refund-handler/)).toBeTruthy();
fireEvent.click(
within(createDialog).getByRole("button", { name: "Create Script draft" }),
);
fireEvent.click(within(createDialog).getByRole("button", { name: "Create member" }));

expect(await screen.findByTestId("studio-script-build-panel")).toBeTruthy();
expect(screen.getByLabelText("Script ID")).toHaveValue("refund-handler");
Expand Down Expand Up @@ -3805,7 +3803,7 @@ describe("StudioPage", () => {
);

expect(
within(createDialog).getByRole("button", { name: "Create Script draft" })
within(createDialog).getByRole("button", { name: "Create member" })
).toBeDisabled();
expect(screen.getByRole("dialog", { name: "Create member" })).toBeTruthy();
});
Expand All @@ -3832,12 +3830,12 @@ describe("StudioPage", () => {
).toHaveAttribute("aria-pressed", "true");
expect(within(createDialog).getByLabelText("Script name")).toHaveValue("script-1");
expect(
within(createDialog).getByRole("button", { name: "Create Script draft" }),
within(createDialog).getByRole("button", { name: "Create member" }),
).toBeEnabled();
});

it("shows GAgent as a builder member kind before its create API lands", async () => {
renderStudioPage("/studio?focus=workflow%3Aworkflow-1&tab=studio");
it("creates a GAgent member authority and opens GAgent Build", async () => {
renderStudioPage("/studio?scopeId=scope-1&focus=workflow%3Aworkflow-1&tab=studio");

fireEvent.click(await screen.findByRole("button", { name: "Create member" }));
const createDialog = await screen.findByRole("dialog", { name: "Create member" });
Expand All @@ -3847,22 +3845,42 @@ describe("StudioPage", () => {
});
fireEvent.click(gagentChip);

expect(gagentChip).toHaveAttribute("aria-pressed", "true");
await waitFor(() => {
expect(gagentChip).toHaveAttribute("aria-pressed", "true");
});
expect(within(createDialog).queryByLabelText("Member name")).toBeNull();
const gagentNameInput = await within(createDialog).findByLabelText("GAgent name");
expect(gagentNameInput).toHaveValue("gagent-1");
fireEvent.change(gagentNameInput, {
target: {
value: "Orders GAgent",
},
});
expect(
screen.getByText(
"GAgent member authority exists on backend, but this modal still hands off through Build > GAgent for implementation editing and binding prep.",
await screen.findByText(
"GAgent creates a backend member and opens Build > GAgent for actor type, role, prompt, tools, and persistence authoring.",
),
).toBeTruthy();
fireEvent.click(
within(createDialog).getByRole("button", { name: "Open GAgent builder" }),
);
fireEvent.click(within(createDialog).getByRole("button", { name: "Create member" }));

expect(await screen.findByTestId("studio-gagent-build-panel")).toBeTruthy();
expect(studioApi.createMember).toHaveBeenCalledWith(
expect.objectContaining({
scopeId: "scope-1",
displayName: "Orders GAgent",
implementationKind: "gagent",
}),
);
await waitFor(() => {
expect(message.success).toHaveBeenCalledWith(
"Created GAgent member Orders GAgent and opened Build.",
);
});
await waitFor(() => {
const searchParams = new URLSearchParams(window.location.search);
expect(searchParams.get("tab")).toBe("gagents");
expect(searchParams.get("step")).toBe("build");
expect(searchParams.get("member")).toBe("member:orders-gagent");
});
});

Expand Down
163 changes: 131 additions & 32 deletions apps/aevatar-console-web/src/pages/studio/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,25 @@ function buildInventoryScriptName(
return `script-${Date.now()}`;
}

function buildInventoryGAgentName(
members: ReadonlyArray<StudioMemberSummary>,
): string {
const usedNames = new Set(
members
.map((member) => normalizeComparableText(member.displayName))
.filter(Boolean),
);

for (let index = 1; index < 1000; index += 1) {
const candidate = `gagent-${index}`;
if (!usedNames.has(candidate)) {
return candidate;
}
}

return `gagent-${Date.now()}`;
}

function upsertStudioMemberRosterMember(
roster: StudioMemberRoster | undefined,
scopeId: string,
Expand Down Expand Up @@ -2810,6 +2829,10 @@ const StudioPage: React.FC = () => {
() => buildInventoryScriptName(availableScopeScripts, studioScopeMembers),
[availableScopeScripts, studioScopeMembers],
);
const suggestedCreateGAgentName = useMemo(
() => buildInventoryGAgentName(studioScopeMembers),
[studioScopeMembers],
);
const publishedScopeServiceRevisionQueries = useQueries({
queries: publishedScopeServices.map((service) => {
const serviceId = trimOptional(service.serviceId);
Expand Down Expand Up @@ -4755,7 +4778,9 @@ const StudioPage: React.FC = () => {
useEffect(() => {
if (
!createMemberModalOpen ||
(createMemberKind !== 'workflow' && createMemberKind !== 'script')
(createMemberKind !== 'workflow' &&
createMemberKind !== 'script' &&
createMemberKind !== 'gagent')
) {
return;
}
Expand Down Expand Up @@ -4867,19 +4892,80 @@ const StudioPage: React.FC = () => {
return;
}

setCreateMemberModalOpen(false);
setCreateMemberTeamId('');
history.push(
buildStudioRoute({
scopeId: resolvedStudioScopeId || undefined,
teamId: createMemberTeamId || undefined,
step: 'build',
tab: 'gagents',
}),
);
setBuildSurface('gagent');
setStudioSurface('build');
void message.info('Opened GAgent builder.');
const gAgentDisplayName = trimOptional(createMemberName);
if (!gAgentDisplayName) {
void message.warning('GAgent member name is required.');
return;
}

if (
studioScopeMembers.some(
(member) =>
normalizeComparableText(member.displayName) ===
normalizeComparableText(gAgentDisplayName) &&
normalizeStudioMemberBindingImplementationKind(member.implementationKind) ===
'gagent',
)
) {
void message.warning('A GAgent member with the same name already exists.');
return;
}

if (!resolvedStudioScopeId) {
void message.warning('Connect a workspace before creating a GAgent member.');
return;
}

setInventoryBusyKey('create');
setInventoryBusyAction('create');
try {
const createdGAgentMember = await studioApi.createMember({
scopeId: resolvedStudioScopeId,
displayName: gAgentDisplayName,
implementationKind: 'gagent',
...(createMemberTeamId ? { teamId: createMemberTeamId } : {}),
});
queryClient.setQueryData<StudioMemberRoster>(
['studio-scope-members', resolvedStudioScopeId],
(current) =>
upsertStudioMemberRosterMember(
current,
resolvedStudioScopeId,
createdGAgentMember,
),
);
void queryClient.invalidateQueries({
queryKey: ['studio-scope-members', resolvedStudioScopeId],
});
setSelectedWorkflowId('');
setSelectedScriptId('');
setTemplateWorkflow('');
setCreateMemberModalOpen(false);
setCreateMemberTeamId('');
history.push(
buildStudioRoute({
scopeId: resolvedStudioScopeId,
teamId: createMemberTeamId || undefined,
memberKey: `member:${createdGAgentMember.memberId}`,
step: 'build',
tab: 'gagents',
}),
);
setBuildSurface('gagent');
setStudioSurface('build');
void message.success(
`Created GAgent member ${createdGAgentMember.displayName} and opened Build.`,
);
} catch (memberError) {
void message.error(
memberError instanceof Error
? `Studio could not register the GAgent member authority: ${memberError.message}`
: 'Studio could not register the GAgent member authority.',
);
} finally {
setInventoryBusyKey('');
setInventoryBusyAction('');
}
return;
}

Expand Down Expand Up @@ -8894,13 +8980,7 @@ const StudioPage: React.FC = () => {
title="Create member"
onCancel={closeCreateMemberFlow}
onOk={() => void handleCreateMember(createMemberKind)}
okText={
createMemberKind === 'workflow'
? 'Create member'
: createMemberKind === 'script'
? 'Create Script draft'
: 'Open GAgent builder'
}
okText="Create member"
okButtonProps={{
disabled:
inventoryBusyAction === 'create' ||
Expand All @@ -8912,7 +8992,9 @@ const StudioPage: React.FC = () => {
(createMemberKind === 'script' &&
(!appContextQuery.data?.features.scripts ||
!createScriptId ||
createScriptIdAlreadyExists)),
createScriptIdAlreadyExists)) ||
(createMemberKind === 'gagent' &&
(!resolvedStudioScopeId || !trimOptional(createMemberName))),
loading: inventoryBusyAction === 'create',
}}
cancelButtonProps={{
Expand Down Expand Up @@ -8953,6 +9035,8 @@ const StudioPage: React.FC = () => {
setCreateMemberName(suggestedCreateWorkflowName);
} else if (kind === 'script') {
setCreateMemberName(suggestedCreateScriptName);
} else {
setCreateMemberName(suggestedCreateGAgentName);
}
}}
>
Expand All @@ -8961,24 +9045,37 @@ const StudioPage: React.FC = () => {
))}
</div>
<div style={inventoryCreateHintStyle}>
Choose the implementation kind first. Workflow entry now
registers a backend member authority; Script creates a named
draft identity before Build; GAgent opens its Build workspace
for implementation editing and binding prep.
Choose the implementation kind first. Studio creates the
backend member authority, then opens the matching Build
surface for Workflow, Script, or GAgent authoring.
</div>
</div>
{createMemberKind === 'workflow' || createMemberKind === 'script' ? (
{createMemberKind === 'workflow' ||
createMemberKind === 'script' ||
createMemberKind === 'gagent' ? (
<label style={inventoryCreateFieldStackStyle}>
<span style={inventoryCreateFieldLabelStyle}>
{createMemberKind === 'workflow' ? 'Member name' : 'Script name'}
{createMemberKind === 'script'
? 'Script name'
: createMemberKind === 'gagent'
? 'GAgent name'
: 'Member name'}
</span>
<input
aria-label={createMemberKind === 'workflow' ? 'Member name' : 'Script name'}
aria-label={
createMemberKind === 'script'
? 'Script name'
: createMemberKind === 'gagent'
? 'GAgent name'
: 'Member name'
}
onChange={(event) => setCreateMemberName(event.target.value)}
placeholder={
createMemberKind === 'workflow'
? suggestedCreateWorkflowName
: suggestedCreateScriptName
: createMemberKind === 'script'
? suggestedCreateScriptName
: suggestedCreateGAgentName
}
ref={createMemberNameInputRef}
style={inventoryCreateInputStyle}
Expand All @@ -8999,8 +9096,10 @@ const StudioPage: React.FC = () => {
{createMemberKind === 'workflow'
? 'Workflow members currently start from a blank workflow draft with an empty canvas, and Studio also registers the member authority in backend once the draft is created.'
: createMemberKind === 'script'
? 'Script starts as a named draft. It becomes a callable member only after Save script is catalog-applied and Bind succeeds.'
: 'GAgent member authority exists on backend, but this modal still hands off through Build > GAgent for implementation editing and binding prep.'}
? 'Script creates a backend member and opens a stable script draft identity in Build. It becomes callable after Save script is catalog-applied and Bind succeeds.'
: resolvedStudioScopeId
? 'GAgent creates a backend member and opens Build > GAgent for actor type, role, prompt, tools, and persistence authoring.'
: 'Connect a workspace before creating a GAgent member.'}
</div>
{createMemberKind === 'workflow' ? (
<label style={inventoryCreateFieldStackStyle}>
Expand Down
Loading