Skip to content
Open
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
13 changes: 13 additions & 0 deletions .github/workflows/harness-sidecar-release-gate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ on:
- 'tests/cli/test_generated_agent_harness_sidecar.py'
- 'tests/cli/test_managed_sidecar_source.py'
- 'tests/cli/test_legacy_runtime_recovery.py'
- 'tests/cli/test_runtime_update_recovery.py'
- 'tests/cli/source_overlay/**'
- 'veadk/cli/runtime_update_recovery.py'
- 'veadk/integrations/agentkit/source_overlay.py'
- 'tests/cli/test_generated_agent_backend_codegen_extended.py'
- 'tests/cli/test_generated_agent_sidecar_runtime.py'
- 'tests/cli/test_generated_agent_mcp.py'
Expand Down Expand Up @@ -47,6 +51,10 @@ on:
- 'tests/cli/test_generated_agent_harness_sidecar.py'
- 'tests/cli/test_managed_sidecar_source.py'
- 'tests/cli/test_legacy_runtime_recovery.py'
- 'tests/cli/test_runtime_update_recovery.py'
- 'tests/cli/source_overlay/**'
- 'veadk/cli/runtime_update_recovery.py'
- 'veadk/integrations/agentkit/source_overlay.py'
- 'tests/cli/test_generated_agent_backend_codegen_extended.py'
- 'tests/cli/test_generated_agent_sidecar_runtime.py'
- 'tests/cli/test_generated_agent_mcp.py'
Expand Down Expand Up @@ -134,6 +142,11 @@ jobs:
pids+=("$!")
(
python -m pytest -q \
tests/cli/source_overlay \
tests/cli/test_runtime_update_recovery.py \
tests/cli/test_legacy_runtime_recovery.py \
tests/cli/test_studio_rbac.py::test_source_preserving_overlay_reopen_keeps_mcp_and_private_source \
tests/integrations/agentkit/test_app.py::test_source_overlay_metadata_is_public_and_never_a_builder_draft \
tests/frontend/server/test_runtime_iam.py \
tests/cli/test_generated_agent_mcp.py \
tests/cli/test_studio_a2a_deployment.py \
Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"dev": "vite",
"build": "node scripts/build.mjs",
"test": "node --test tests/*.test.mjs && npm run test:mpa-agent-info",
"test:mpa-agent-info": "vitest run tests/mpaAgentInfoRail.test.tsx tests/skillDocumentEditor.test.tsx tests/mpaCreation.test.tsx tests/mpaBasicDetails.test.tsx",
"test:mpa-agent-info": "vitest run tests/projectPreviewLifecycle.test.tsx tests/mpaAgentInfoRail.test.tsx tests/skillDocumentEditor.test.tsx tests/mpaCreation.test.tsx tests/mpaBasicDetails.test.tsx",
"check:i18n": "node scripts/check-i18n.mjs",
"test:sandbox-download-coverage": "vitest run --coverage -c vitest.sandbox-download.config.ts",
"test:mpa-cron-coverage": "vitest run --coverage -c vitest.mpa-cron.config.ts",
Expand Down
15 changes: 10 additions & 5 deletions frontend/src/create/CustomCreate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2556,6 +2556,7 @@ function treeProblems(
root: AgentDraft,
duplicateNames: ReadonlySet<string>,
path: NodePath = [],
editMode?: "source-preserving" | "regenerate",
): TreeProblem[] {
const out: TreeProblem[] = [];
if (path.length === 0) {
Expand All @@ -2574,7 +2575,8 @@ function treeProblems(
}
const remote = isA2aType(root.agentType);
const p = nodeProblem(root, duplicateNames, path.length === 0);
if (p) {
// Source-preserving updates retain runtime-owned, possibly dynamic prompts.
if (p && !(p === "missingPrompt" && editMode === "source-preserving")) {
out.push({
path,
name: remote ? "" : root.name.trim(),
Expand All @@ -2584,7 +2586,7 @@ function treeProblems(
}
if (nodeAcceptsChildren(root)) {
root.subAgents.forEach((c, i) =>
out.push(...treeProblems(c, duplicateNames, [...path, i])),
out.push(...treeProblems(c, duplicateNames, [...path, i], editMode)),
);
}
return out;
Expand Down Expand Up @@ -4427,7 +4429,9 @@ export function CustomCreate({
const showNameError =
showErrors || touchedAgentNamePaths.has(selectedNamePathKey);
const descriptionMissing = !a2a && node.description.trim().length === 0;
const instructionMissing = node.instruction.trim().length === 0;
const instructionMissing =
deploymentTarget?.editMode !== "source-preserving" &&
node.instruction.trim().length === 0;
const a2aRegistrySpaceMissing =
a2a && !node.a2aRegistry?.registrySpaceId.trim();
const invalidClass = (missing: boolean, visible = showErrors) =>
Expand All @@ -4437,8 +4441,8 @@ export function CustomCreate({

// Whole-tree validation: every node must satisfy its type's requirements.
const problems = useMemo(
() => treeProblems(draft, duplicateNames),
[draft, duplicateNames],
() => treeProblems(draft, duplicateNames, [], deploymentTarget?.editMode),
[draft, duplicateNames, deploymentTarget?.editMode],
);
const canFinish = problems.length === 0;
const providerDraft = useMemo(
Expand Down Expand Up @@ -6900,6 +6904,7 @@ export function CustomCreate({
}
deploymentActionTargetId="cw-publish-primary-action"
deploymentRuntimeId={deploymentTarget?.runtimeId}
deploymentEditMode={deploymentTarget?.editMode}
deploymentMpaInstanceId={deploymentTarget?.mpaInstanceId}
deploymentRuntimeName={deploymentRuntimeName}
deploymentRuntimeRevision={deploymentTarget?.etag}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/create/agentDraftStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ function stripBrowserStorageSecrets(
mcpTools: draft.mcpTools.map((tool) => {
if (
!tool.authTokenEnv ||
!transientMcpKeys.has(tool.authTokenEnv)
!transientMcpKeys.has(tool.authTokenEnv) ||
tool.credentialConfigured === true
) {
return tool;
}
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/ui/ProjectPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,8 @@ export interface ProjectPreviewProps {
deploymentActionTargetId?: string;
/** Existing Runtime id when this deployment updates an Agent in place. */
deploymentRuntimeId?: string;
/** Source-preserving updates publish overlays without managing MPA Profiles. */
deploymentEditMode?: "source-preserving" | "regenerate";
/** Existing MPA instance id when it differs from the platform Runtime id. */
deploymentMpaInstanceId?: string;
/** Existing platform Runtime resource name when publishing an update. */
Expand Down Expand Up @@ -850,6 +852,7 @@ export function ProjectPreview({
deploymentConfirmation,
deploymentActionTargetId,
deploymentRuntimeId,
deploymentEditMode,
deploymentMpaInstanceId,
deploymentRuntimeName,
deploymentRuntimeRevision,
Expand Down Expand Up @@ -1021,7 +1024,10 @@ export function ProjectPreview({
const githubUpdateOffloaded =
Boolean(deploymentRuntimeId && githubCicdBinding?.pipelineId);
const isMpaDeployment = Boolean(agentDraft);
const shouldRunMpaLifecycle = isMpaDeployment && !githubUpdateOffloaded;
const shouldRunMpaLifecycle =
isMpaDeployment &&
deploymentEditMode !== "source-preserving" &&
!githubUpdateOffloaded;
const baseDeploymentSteps = deploymentPrimaryPane
? codePackageDeploySteps(t)
: deploySteps(t);
Expand Down
119 changes: 119 additions & 0 deletions frontend/tests/agentDraftStorage.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -698,3 +698,122 @@ test("reports browser storage write failures with a recovery action", () => {
/Delete unused drafts or clear this site's storage/,
);
});


const authBuild = await build({
entryPoints: [
fileURLToPath(new URL("../src/create/mcpAuth.ts", import.meta.url)),
],
bundle: true,
format: "esm",
platform: "node",
target: "node20",
write: false,
});
const { hydrateMcpCredentialValues, prepareMcpAuth, updateMcpAuthTokenInput } =
await import(
`data:text/javascript;base64,${Buffer.from(authBuild.outputFiles[0].contents).toString("base64")}`
);

for (const scope of ["root", "child", "workflow"]) {
test(`refresh retains the published MCP reference after hydration in ${scope}`, () => {
const storage = memoryStorage();
const owner = draft({
name: "published_agent",
mcpTools: [
{
name: "metrics",
url: "https://mcp.example.com/metrics",
transport: "http",
authTokenEnv: "PUBLISHED_METRICS_AUTH_TOKEN",
credentialConfigured: true,
},
],
deployment: {
envValues: { PUBLISHED_METRICS_AUTH_TOKEN: "synthetic-hydrated-value" },
},
});
const source =
scope === "root"
? owner
: scope === "child"
? draft({ subAgents: [owner] })
: draft({
workflow: {
type: "sequential",
edges: [],
nodes: [{ id: "node", agent: owner }],
},
});
const hydrated = hydrateMcpCredentialValues(source, [
{
agentName: "published_agent",
authTokenEnv: "PUBLISHED_METRICS_AUTH_TOKEN",
url: "https://mcp.example.com/metrics",
value: "synthetic-hydrated-value",
},
]);
const select = (value) =>
scope === "root"
? value
: scope === "child"
? value.subAgents[0]
: value.workflow.nodes[0].agent;
assert.equal(
select(hydrated).mcpTools[0].authToken,
"synthetic-hydrated-value",
);
writeWorkspaceDrafts(storage, "fixture", [
{ id: "published", updatedAt: 1, draft: hydrated },
]);
const loaded = loadWorkspaceDrafts(storage, "fixture")[0].draft;
assert.equal(
select(loaded).mcpTools[0].authTokenEnv,
"PUBLISHED_METRICS_AUTH_TOKEN",
);
assert.equal(select(loaded).mcpTools[0].credentialConfigured, true);
const prepared = prepareMcpAuth(loaded);
assert.equal(
select(prepared.draft).mcpTools[0].authTokenEnv,
"PUBLISHED_METRICS_AUTH_TOKEN",
);
assert.equal(select(prepared.draft).mcpTools[0].authToken, undefined);
assert.deepEqual(prepared.envValues, {});
assert.equal(
select(loaded).deployment.envValues.PUBLISHED_METRICS_AUTH_TOKEN,
undefined,
);
assert.doesNotMatch(
storage.value(workspaceDraftsKey("fixture")),
/synthetic-hydrated-value/,
);
assert.deepEqual(sanitizeAgentDraftForStorage(loaded), loaded);
assert.equal(
select(hydrated).mcpTools[0].authToken,
"synthetic-hydrated-value",
);
});
}

for (const value of ["", "synthetic-replacement-value"]) {
test(`storage does not restore a configured reference after explicit ${value ? "replacement" : "removal"}`, () => {
const configured = {
name: "metrics",
transport: "http",
url: "https://mcp.example.com/metrics",
authTokenEnv: "PUBLISHED_METRICS_AUTH_TOKEN",
authToken: "synthetic-old-value",
credentialConfigured: true,
};
const edited = updateMcpAuthTokenInput(configured, value);
const source = draft({ mcpTools: [edited] });
const safe = sanitizeAgentDraftForStorage(source);
assert.equal(safe.mcpTools[0].authTokenEnv, undefined);
assert.notEqual(safe.mcpTools[0].credentialConfigured, true);
assert.equal(safe.mcpTools[0].authToken, undefined);
assert.doesNotMatch(
JSON.stringify(safe),
/synthetic-old-value|synthetic-replacement-value/,
);
});
}
106 changes: 106 additions & 0 deletions frontend/tests/projectPreviewLifecycle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { ProjectPreview, type ProjectPreviewProps } from "../src/ui/ProjectPreview";
import { emptyDraft } from "../src/create/types";
import * as api from "../src/adk/client";

vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
const t = (key: string) => key;
return { ...actual, useTranslation: () => ({ t }) };
});
vi.mock("../src/adk/client", () => ({
applyMpaProfileAfterDeployment: vi.fn(),
retryMpaAgentOperation: vi.fn(),
checkRuntimeNameAvailability: vi.fn(),
listIdentityUserPools: vi.fn(),
revealModelApiKey: vi.fn(),
bindGithubCicdRuntime: vi.fn(),
initializeGithubDeliveryMain: vi.fn(),
syncGithubCicdRuntime: vi.fn(),
RuntimeProbeError: class extends Error {},
}));
vi.mock("../src/telemetry", () => ({
beginAgentDeploy: () => ({ succeed: vi.fn(), fail: vi.fn() }),
beginAgentSourceDownload: vi.fn(),
classifyTelemetryError: () => ({}),
safeTelemetryErrorMessage: (error: Error) => error.message,
}));
vi.mock("../src/create/AgentBuildCanvas", () => ({ AgentBuildCanvas: () => null }));
vi.mock("../src/ui/CodeBrowserDialog", () => ({ ProjectCodeBrowser: () => null }));
vi.mock("../src/ui/GithubCicdPanel", () => ({ GithubCicdPanel: () => null }));

let host: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.resetAllMocks();
vi.mocked(api.checkRuntimeNameAvailability).mockResolvedValue({ available: true });
vi.mocked(api.applyMpaProfileAfterDeployment).mockResolvedValue({
operationId: "fixture-profile", status: "succeeded", stage: "runnable",
} as Awaited<ReturnType<typeof api.applyMpaProfileAfterDeployment>>);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
function button(text: string) {
const element = [...document.querySelectorAll("button")].find((node) => node.textContent?.trim() === text);
expect(element, text).toBeDefined();
return element!;
}
async function deploy(overrides: Partial<ProjectPreviewProps> = {}) {
const onDeploy = vi.fn().mockResolvedValue({ runtimeId: "fixture-runtime", region: "cn-beijing" });
const onDeploymentTaskChange = vi.fn();
const onDeploymentComplete = vi.fn();
const props = {
project: { name: "fixture-agent", files: [{ path: "agent.py", content: "# fixture" }] },
agentDraft: { ...emptyDraft(), name: "fixture-agent" },
deploymentRuntimeId: "fixture-runtime",
showGitSync: false, showMessageChannels: false, showEvaluationSets: false,
onDeploy, onDeploymentTaskChange, onDeploymentComplete,
...overrides,
};
await act(async () => root.render(<ProjectPreview {...props} />));
await act(async () => button("projectPreview.deploy").click());
await act(async () => button(props.deploymentRuntimeId ? "projectPreview.confirm.update" : "projectPreview.confirm.deploy").click());
return { onDeploy, onDeploymentComplete, tasks: onDeploymentTaskChange.mock.calls.map(([task]) => task) };
}
it("completes a source-preserving update once without Profile calls, stages or retries", async () => {
vi.mocked(api.applyMpaProfileAfterDeployment).mockRejectedValue(new Error("mpa_operation_service_unavailable"));
const { onDeploy, onDeploymentComplete, tasks } = await deploy({ deploymentEditMode: "source-preserving" });
expect(onDeploy).toHaveBeenCalledTimes(1);
expect(api.applyMpaProfileAfterDeployment).not.toHaveBeenCalled();
expect(api.retryMpaAgentOperation).not.toHaveBeenCalled();
expect(onDeploymentComplete).toHaveBeenCalledTimes(1);
expect(tasks.at(-1)).toMatchObject({ status: "success", phase: "complete", mpaProfile: false, mpaSmoke: false });
expect(tasks.every((task) => !task.mpaProfile && !task.mpaSmoke && !task.retry && !["profile_applying", "smoke_running", "runnable"].includes(task.phase))).toBe(true);
});
it("retains Profile application for regenerate updates", async () => {
const { tasks } = await deploy({ deploymentEditMode: "regenerate", deploymentRuntimeRevision: "revision-fixture" });
expect(api.applyMpaProfileAfterDeployment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ operationKind: "update", runtimeId: "fixture-runtime", runtimeRevision: "revision-fixture" }));
expect(tasks.at(-1)).toMatchObject({ status: "success", mpaProfile: true, mpaSmoke: false });
});
it("does not hide a real MPA Profile failure or complete the failed operation", async () => {
vi.mocked(api.applyMpaProfileAfterDeployment).mockRejectedValue(new Error("mpa_operation_service_unavailable"));
const { tasks, onDeploymentComplete } = await deploy({ deploymentEditMode: "regenerate" });
expect(api.applyMpaProfileAfterDeployment).toHaveBeenCalledTimes(1);
expect(tasks.at(-1)).toMatchObject({ status: "error", phase: "profile_applying" });
expect(tasks.some((task) => task.status === "success")).toBe(false);
expect(onDeploymentComplete).not.toHaveBeenCalled();
});
it("retains create Profile and smoke stages for a new MPA deployment", async () => {
const { tasks } = await deploy({ deploymentRuntimeId: undefined });
expect(api.applyMpaProfileAfterDeployment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ operationKind: "create" }));
expect(tasks.at(-1)).toMatchObject({ status: "success", phase: "runnable", mpaProfile: true, mpaSmoke: true });
});
it("does not add MPA lifecycle to a code package without a draft", async () => {
const { tasks } = await deploy({ agentDraft: undefined });
expect(api.applyMpaProfileAfterDeployment).not.toHaveBeenCalled();
expect(tasks.at(-1)).toMatchObject({ status: "success", phase: "complete", mpaProfile: false });
});
3 changes: 2 additions & 1 deletion frontend/tests/runtimeScalingDeploy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ test("structured Agent deployments apply MPA Profile after Runtime publish", ()
assert.match(projectPreviewSource, /applyMpaProfileAfterDeployment\(/);
assert.match(
projectPreviewSource,
/const isMpaDeployment = Boolean\(agentDraft\);[\s\S]*?const shouldRunMpaLifecycle = isMpaDeployment && !githubUpdateOffloaded/,
/const isMpaDeployment = Boolean\(agentDraft\);[\s\S]*?const shouldRunMpaLifecycle =\s*isMpaDeployment &&\s*deploymentEditMode !== "source-preserving" &&\s*!githubUpdateOffloaded/,
);
assert.match(
projectPreviewSource,
Expand Down Expand Up @@ -136,6 +136,7 @@ test("structured Agent deployments apply MPA Profile after Runtime publish", ()
assert.match(customCreateSource, /applyMpaProfileAfterDeployment/);
assert.match(customCreateSource, /runtimeRevision: deploymentTarget\?\.etag/);
assert.match(customCreateSource, /deploymentRuntimeRevision=\{deploymentTarget\?\.etag\}/);
assert.match(customCreateSource, /deploymentEditMode=\{deploymentTarget\?\.editMode\}/);
assert.doesNotMatch(codePackageCreateSource, /agentDraft=\{/);
assert.doesNotMatch(codePackageCreateSource, /applyMpaProfileAfterDeployment/);
});
Expand Down
Loading
Loading